From 506f4a57eaa07236c6d738e905ef82d7f53dc869 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:20:47 -0700 Subject: [PATCH 001/186] Documents: Next and Previous Change reach every change in a reformatted paragraph A paragraph's formatting change spans the paragraph, so Next Change went on from its end and never reached the words typed inside it (it stayed on the formatting change), and Previous Change from one of those words never reached the paragraph's change. From a selected change, Next and Previous now step through the changes in order. --- src/documents/src/views/review.js | 10 ++++- src/documents/tests/review-navigation.test.js | 38 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 src/documents/tests/review-navigation.test.js diff --git a/src/documents/src/views/review.js b/src/documents/src/views/review.js index 288a426e9..1cf5abe3b 100644 --- a/src/documents/src/views/review.js +++ b/src/documents/src/views/review.js @@ -276,8 +276,14 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = const engine = editor(); if (!engine) return; const body = engine.session.body, list = body.revisions; if (!list.length) return; - const [a, b] = body.range, at = from ?? (direction > 0 ? b : a); - const next = direction > 0 ? list.find(r => r.start >= at && !(r.start === a && r.end === b)) || list[0] : [...list].reverse().find(r => r.end <= at && !(r.start === a && r.end === b)) || list.at(-1); + const [a, b] = body.range, at = from ?? (direction > 0 ? b : a), length = body.text.length; + // From a selected change, step through the list: a paragraph's formatting change spans the changes inside the + // paragraph, so going on from its end skipped them, and going back from one of them skipped the paragraph's. + const same = r => r.start === a && Math.min(r.end, length) === b; + const went = r => navigated && r.type === navigated.type && r.author === navigated.author && r.date === navigated.date; + const current = from !== undefined ? -1 : list.findIndex(r => same(r) && went(r)) >= 0 ? list.findIndex(r => same(r) && went(r)) : list.findIndex(same); + const next = current >= 0 ? list[(current + direction + list.length) % list.length] + : direction > 0 ? list.find(r => r.start >= at && !same(r)) || list[0] : [...list].reverse().find(r => r.end <= at && !same(r)) || list.at(-1); engine.finishComposition(); if (engine.session.path !== null) engine.editStory(null); body.select(next.start, Math.min(next.end, body.text.length)); navigated = next; diff --git a/src/documents/tests/review-navigation.test.js b/src/documents/tests/review-navigation.test.js new file mode 100644 index 000000000..1816e6c74 --- /dev/null +++ b/src/documents/tests/review-navigation.test.js @@ -0,0 +1,38 @@ +// @vitest-environment jsdom +import { beforeEach, afterEach, it, expect, vi } from 'vitest'; +import { CanvasEditor } from '../src/engine/canvas-editor.js'; +import { paragraph } from '../src/engine/model.js'; +import { createReview } from '../src/views/review.js'; + +let editor, host, review; +beforeEach(() => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + fontKerning: 'auto', getTransform: () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }), measureText: text => ({ width: [...text].length * 8 }), + setTransform() {}, fillRect() {}, fillText() {}, save() {}, restore() {}, beginPath() {}, rect() {}, clip() {}, moveTo() {}, lineTo() {}, stroke() {}, setLineDash() {}, + }); + host = document.createElement('div'); document.body.append(host); +}); +afterEach(() => { review?.destroy(); editor?.destroy(); review = editor = null; document.body.replaceChildren(); vi.restoreAllMocks(); }); + +function open(texts) { + editor = new CanvasEditor(host, { paragraphs: texts.map(text => paragraph(text)), comments: [] }); + review = createReview({ editor: () => editor, pkg: () => ({}), host: () => host, error: e => { throw e; }, author: () => 'Ada' }); + review.refresh(true); + return editor; +} +const selected = () => { const [a, b] = editor.model.range; return editor.model.text.slice(a, b); }; + +it('Next and Previous Change visit the changes inside a reformatted paragraph and the paragraph\'s own change', () => { + open(['First line.', 'Second line here.', 'Third line.']); + review.commands.toggleTracking(true); + editor.execute(() => { editor.model.select(19, 19); editor.model.replace('new '); }); + editor.execute(() => { editor.model.select(14, 14); editor.model.setParagraphFormat({ alignment: 'center' }); }); + expect(editor.model.revisions.map(r => r.type)).toEqual(['format', 'ins']); + editor.model.select(0, 0); + const forward = []; + for (let i = 0; i < 3; i++) { review.commands.goChange(1); forward.push(selected()); } + expect(forward).toEqual(['Second new line here.\n', 'new ', 'Second new line here.\n']); + const back = []; + for (let i = 0; i < 3; i++) { review.commands.goChange(-1); back.push(selected()); } + expect(back).toEqual(['new ', 'Second new line here.\n', 'new ']); +}); From a450f8b0df25dbc960d51dceab6858708bb77843 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:21:51 -0700 Subject: [PATCH 002/186] Documents: Keep a half-written comment reply or edit when you click back into the document The comment column is rebuilt when the caret moves to another thread, and only the text box that had the keyboard kept its words: a reply or an edit being written was emptied by a click in the document. Unsent replies and edits are now kept per thread until they are posted or cancelled. --- src/documents/src/views/review.js | 15 +++++-- src/documents/tests/comment-column.test.js | 50 ++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 src/documents/tests/comment-column.test.js diff --git a/src/documents/src/views/review.js b/src/documents/src/views/review.js index 1cf5abe3b..769b1e7a8 100644 --- a/src/documents/src/views/review.js +++ b/src/documents/src/views/review.js @@ -43,6 +43,10 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = let activeId = null, pinned = false, pinnedRange = '', draft = null, editing = null, signature = '', positionsKey = ''; // The tracked change last selected with Previous/Next Change or from the Reviewing Pane. let navigated = null; + // Replies and edits being written, by thread or comment, with the document they belong to: rebuilding the column + // (the caret moving to another thread, a click in the document) kept only the words in the focused box. + const unsent = new Map(); + let unsentBody = null; const cards = new Map(); const readAloud = createReadAloud({ editor, onState: () => onChange() }); /* ---- Reviewing Pane: every comment and change in document order ---- */ @@ -310,7 +314,9 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = function avatar(name, initials) { return el('span.wcc-avatar', { 'aria-hidden': 'true', style: { background: avatarColor(name) } }, (initials || initialsFor(name) || '?').slice(0, 2)); } - function textEditor({ value = '', placeholder, submitLabel, onSubmit, onCancel }) { + function textEditor({ value = '', placeholder, submitLabel, onSubmit, onCancel, key = null }) { + if (key && unsent.has(key)) value = unsent.get(key); + if (key) { const submitted = onSubmit, cancelled = onCancel; onSubmit = text => { unsent.delete(key); submitted(text); }; onCancel = () => { unsent.delete(key); cancelled?.(); }; } const input = el('textarea.wcc-input', { rows: 1, placeholder: `${placeholder} (type @ to mention someone)`, 'aria-label': placeholder, 'aria-autocomplete': 'list' }, value); input.value = value; const grow = () => { input.style.height = 'auto'; input.style.height = `${Math.min(200, input.scrollHeight + 2)}px`; }; @@ -337,7 +343,7 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = mention = null; mentions.hidden = true; input.dispatchEvent(new Event('input')); input.focus(); }; - input.addEventListener('input', () => { submit.disabled = !input.value.trim(); grow(); showMentions(); }); + input.addEventListener('input', () => { submit.disabled = !input.value.trim(); grow(); showMentions(); if (key) unsent.set(key, input.value); }); input.addEventListener('blur', () => { mentions.hidden = true; }); input.addEventListener('keydown', e => { if (isComposing(e)) return; @@ -363,7 +369,7 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = const head = el('div.wcc-head', {}, avatar(comment.author, comment.initials), el('div.wcc-meta', {}, el('span.wcc-author', {}, comment.author || 'Unknown author'), el('span.wcc-date', { title: comment.date ? new Date(comment.date).toLocaleString() : '' }, time(comment.date))), more); const body = editing === comment.id - ? textEditor({ value: comment.text, placeholder: 'Edit comment', submitLabel: 'Save', onSubmit: text => { editing = null; run(() => editor().model.editComment(comment.id, text)); refresh(true); }, onCancel: () => { editing = null; refresh(true); editor()?.focus(); } }) + ? textEditor({ value: comment.text, key: `edit:${comment.id}`, placeholder: 'Edit comment', submitLabel: 'Save', onSubmit: text => { editing = null; run(() => editor().model.editComment(comment.id, text)); refresh(true); }, onCancel: () => { editing = null; refresh(true); editor()?.focus(); } }) // Only text with an @ can mention anyone. : el('div.wcc-text', {}, ...renderMentions(comment.text, String(comment.text || '').includes('@') ? names() : [])); return el(`div.wcc-entry${editing === comment.id ? '.is-editing' : ''}`, { dataset: { commentId: comment.id } }, head, body); @@ -380,7 +386,7 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = } node.append(entry(thread.root, thread, true, names), ...thread.replies.map(reply => entry(reply, thread, false, names))); if (done) node.append(el('div.wcc-resolved', {}, el('span', {}, 'Resolved'), el('button.btn.btn-sm', { type: 'button', disabled: !ready(), onclick: () => { run(() => editor().model.setCommentDone(thread.id, false)); refresh(true); } }, 'Reopen'))); - else if (ready()) node.append(el('div.wcc-reply', {}, textEditor({ placeholder: 'Reply', submitLabel: 'Reply', + else if (ready()) node.append(el('div.wcc-reply', {}, textEditor({ key: `reply:${thread.id}`, placeholder: 'Reply', submitLabel: 'Reply', onSubmit: text => { run(() => editor().model.replyToComment(thread.id, { ...me(), text })); refresh(true); }, onCancel: () => { activeId = null; pinned = false; refresh(true); editor()?.focus(); } })), el('button.btn.btn-sm.wcc-resolve', { type: 'button', title: 'Resolve thread', onclick: () => { run(() => editor().model.setCommentDone(thread.id, true)); refresh(true); } }, 'Resolve')); @@ -491,6 +497,7 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = const engine = editor(); if (!engine || !host()) { layer.remove(); return; } const scroller = host(); + if (unsentBody !== engine.session.body) { unsent.clear(); unsentBody = engine.session.body; } if (layer.parentNode !== scroller) { scroller.append(layer); resize.disconnect(); resize.observe(scroller); } if (scroller.parentElement && readAloud.bar.parentNode !== scroller.parentElement) scroller.parentElement.append(readAloud.bar); renderPane(); diff --git a/src/documents/tests/comment-column.test.js b/src/documents/tests/comment-column.test.js new file mode 100644 index 000000000..51ab8edac --- /dev/null +++ b/src/documents/tests/comment-column.test.js @@ -0,0 +1,50 @@ +// @vitest-environment jsdom +import { beforeEach, afterEach, it, expect, vi } from 'vitest'; +import { CanvasEditor } from '../src/engine/canvas-editor.js'; +import { paragraph } from '../src/engine/model.js'; +import { createReview } from '../src/views/review.js'; + +let editor, host, review; +beforeEach(() => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + fontKerning: 'auto', getTransform: () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }), measureText: text => ({ width: [...text].length * 8 }), + setTransform() {}, fillRect() {}, fillText() {}, save() {}, restore() {}, beginPath() {}, rect() {}, clip() {}, moveTo() {}, lineTo() {}, stroke() {}, setLineDash() {}, + }); + host = document.createElement('div'); document.body.append(host); +}); +afterEach(() => { review?.destroy(); editor?.destroy(); review = editor = null; document.body.replaceChildren(); vi.restoreAllMocks(); }); + +function open(texts) { + editor = new CanvasEditor(host, { paragraphs: texts.map(text => paragraph(text)), comments: [] }); + review = createReview({ editor: () => editor, pkg: () => ({}), host: () => host, error: e => { throw e; }, author: () => 'Ada' }); + review.refresh(true); + return editor; +} +const card = id => review.layer.querySelector(`[data-thread="${id}"]`); +const type = (textarea, text) => { textarea.value = text; textarea.dispatchEvent(new Event('input')); }; + +it('keeps a reply and an edit being written when the column is rebuilt around them', () => { + open(['First paragraph here.', 'Second paragraph here.']); + const first = editor.model.addComment({ author: 'Ada', text: 'One', range: [0, 5] }); + const second = editor.model.addComment({ author: 'Grace', text: 'Two', range: [22, 28] }); + review.refresh(true); + type(card(first).querySelector('.wcc-reply textarea'), 'Half a reply'); + // The caret goes into the other thread (a click in the document): the column is rebuilt. + editor.model.select(24, 24); review.refresh(); + expect(card(second).classList.contains('is-active')).toBe(true); + expect(card(first).querySelector('.wcc-reply textarea').value).toBe('Half a reply'); + expect(card(first).querySelector('.wcc-reply .wcc-post').disabled).toBe(false); + // Sending it posts the words and leaves the box empty. + card(first).querySelector('.wcc-reply .wcc-post').click(); + expect(editor.model.comments.at(-1)).toMatchObject({ text: 'Half a reply', parentId: first }); + expect(card(first).querySelector('.wcc-reply textarea').value).toBe(''); + // An edit in progress survives the same way, and Cancel forgets it. + review.commands.selectThreadById(second); + card(second).querySelector('.wcc-more').click(); + document.querySelector('[role="menuitem"]').click(); + type(card(second).querySelector('.wcc-entry.is-editing textarea'), 'Two, edited'); + editor.model.select(2, 2); review.refresh(); review.refresh(true); + expect(card(second).querySelector('.wcc-entry.is-editing textarea').value).toBe('Two, edited'); + card(second).querySelector('.wcc-entry.is-editing .wcc-cancel').click(); + expect(editor.model.comments.find(c => c.id === second).text).toBe('Two'); +}); From 53ca6b9fbc8005815e3e146bf2c67b1b043d05d6 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:22:27 -0700 Subject: [PATCH 003/186] Documents: Delete All Resolved Comments also deletes the replies in a thread resolved in Word Word marks only a resolved thread's first comment done. Deleting all resolved comments removed that comment alone, so its replies came back as new, open threads. Deleting resolved comments now deletes each resolved thread whole. --- src/documents/docs/conformance.md | 3 ++- src/documents/src/engine/model.js | 4 +++- src/documents/tests/comment-docx.test.js | 13 +++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/documents/docs/conformance.md b/src/documents/docs/conformance.md index 3c6037cc0..913bae624 100644 --- a/src/documents/docs/conformance.md +++ b/src/documents/docs/conformance.md @@ -22102,7 +22102,8 @@ Supported subset: paragraphs with an `annotationRef` run. The last paragraph carries a `w14:paraId` used by `commentsExtended`. - Add, reply, edit, resolve/reopen, delete a thread, delete all (or all resolved) comments, all - undoable. Creating the first comment adds the comments part, relationship and content type. + undoable. A thread is resolved when its first comment is `done` (Word marks only that one); deleting + resolved comments deletes such threads with their replies. Creating the first comment adds the comments part, relationship and content type. `commentsIds.xml`/`commentsExtensible.xml` entries for removed comments are dropped. - Untouched documents still save byte for byte. diff --git a/src/documents/src/engine/model.js b/src/documents/src/engine/model.js index bb1fd39f0..86570c1d9 100644 --- a/src/documents/src/engine/model.js +++ b/src/documents/src/engine/model.js @@ -1613,7 +1613,9 @@ export class TextDocument { this.didChange(); return true; } deleteAllComments({ resolvedOnly = false } = {}) { - const ids = (this.comments || []).filter(c => !resolvedOnly || c.done).map(c => c.id); + const comments = this.comments || []; + // A thread is resolved when its first comment is (Word marks only that one done): its replies go with it. + const ids = !resolvedOnly ? comments.map(c => c.id) : [...new Set(comments.filter(c => c.done && (!c.parentId || !comments.some(p => p.id === c.parentId))).flatMap(c => commentThread(comments, c.id)))]; if (!ids.length) return false; this.checkpoint(); removeCommentMarkers(this.paragraphs, ids); diff --git a/src/documents/tests/comment-docx.test.js b/src/documents/tests/comment-docx.test.js index 383f47762..3a966fd35 100644 --- a/src/documents/tests/comment-docx.test.js +++ b/src/documents/tests/comment-docx.test.js @@ -115,6 +115,19 @@ describe('DOCX comments', () => { expect((await open(bytes)).model.commentList).toEqual([]); }); + it('deletes a resolved thread\'s replies with it when only its first comment is marked done', async () => { + // A resolved thread as Word writes it: done on the thread's first comment, not on its replies. + const parts = unzipSync(commentedDocx()); + parts['word/commentsExtended.xml'] = strToU8(``); + const { pkg, model } = await open(zipSync(parts)); + expect(model.commentList.map(c => [c.id, c.rootId, c.done])).toEqual([['3', '3', true], ['4', '3', false], ['7', '7', false]]); + expect(model.deleteAllComments({ resolvedOnly: true })).toBe(true); + expect(model.commentList.map(c => c.id)).toEqual(['7']); + const again = await open(await save(pkg, model)); + expect(again.model.commentList.map(c => [c.id, c.text])).toEqual([['7', 'First\nSecond\tpara']]); + expect(strFromU8(unzipSync(await save(pkg, model))['word/document.xml'])).not.toMatch(/w:id="[34]"/); + }); + it('anchors a caret comment to the surrounding word', async () => { const { model } = await open(commentedDocx()); model.select(model.text.indexOf('fox') + 1); From 7c9e4284f83f9339ab59f668ea52499c1b0e8022 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:27:18 -0700 Subject: [PATCH 004/186] Documents: Choosing a change in the Reviewing Pane while editing a header goes to the change The entry selected the change in the body but left the header open for editing, so nothing happened. It now leaves the header, footer or note first, as Next Change and comment entries do. --- src/documents/src/views/review.js | 2 +- .../tests/browser/review-from-stories.spec.js | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 src/documents/tests/browser/review-from-stories.spec.js diff --git a/src/documents/src/views/review.js b/src/documents/src/views/review.js index 769b1e7a8..f9b75c9d2 100644 --- a/src/documents/src/views/review.js +++ b/src/documents/src/views/review.js @@ -64,7 +64,7 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = // with the pane open slow in a document with many changes. const text = body.text, formatSummary = formatSummaries(body); const items = [ - ...changes.map(c => ({ at: c.start, kind: c.move ? (c.type === 'ins' ? 'Moved (to)' : 'Moved (from)') : { ins: 'Inserted', del: 'Deleted', format: 'Formatted' }[c.type] || 'Changed', author: c.author, date: c.date, text: c.type === 'format' ? formatSummary(c) : (text.slice(c.start, Math.min(c.end, text.length)) + (c.end > text.length ? '\n' : '')).replace(/\n/g, ' ¶ ').trim(), select: () => { body.select(c.start, Math.min(c.end, body.text.length)); navigated = c; engine.focus(); engine.revealCaret?.(); onChange(); } })), + ...changes.map(c => ({ at: c.start, kind: c.move ? (c.type === 'ins' ? 'Moved (to)' : 'Moved (from)') : { ins: 'Inserted', del: 'Deleted', format: 'Formatted' }[c.type] || 'Changed', author: c.author, date: c.date, text: c.type === 'format' ? formatSummary(c) : (text.slice(c.start, Math.min(c.end, text.length)) + (c.end > text.length ? '\n' : '')).replace(/\n/g, ' ¶ ').trim(), select: () => { engine.finishComposition(); if (engine.session.path !== null) engine.editStory(null); body.select(c.start, Math.min(c.end, body.text.length)); navigated = c; engine.focus(); engine.revealCaret?.(); onChange(); } })), ...notes.map(t => ({ at: t.start ?? Infinity, kind: t.root.done ? 'Comment (resolved)' : 'Comment', author: t.root.author, date: t.root.date, text: t.root.text, select: () => selectThread(t) })), ].sort((a, b) => a.at - b.at); return { changes, notes, items }; diff --git a/src/documents/tests/browser/review-from-stories.spec.js b/src/documents/tests/browser/review-from-stories.spec.js new file mode 100644 index 000000000..aba4c4d1f --- /dev/null +++ b/src/documents/tests/browser/review-from-stories.spec.js @@ -0,0 +1,35 @@ +import { test, expect } from '@playwright/test'; + +const input = page => page.locator('.canvas-input'); +const engine = 'window.__docs.current.view.engine'; + +async function trackedDocument(page) { + const errors = []; page.on('pageerror', e => errors.push(e.message)); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/mock.html#/new'); + await expect(page.getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + await page.getByRole('textbox', { name: 'Document text' }).fill('Alpha beta gamma.\nSecond paragraph.'); + await page.evaluate(engine => { + const e = eval(engine), m = e.model; + e.execute(() => { m.trackChanges = true; m.select(6); m.replace('new '); m.trackChanges = false; }); + }, engine); + // Edit the header, and type in it. + await page.getByRole('tab', { name: 'Insert' }).click(); + await page.locator('#ribbon-panel-insert').getByRole('button', { name: 'Header', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Edit Header' }).click(); + await expect(input(page)).toHaveAttribute('aria-label', 'Header text'); + await page.keyboard.type('Head'); + await page.getByRole('tab', { name: 'Review' }).click(); + return errors; +} + +test('choosing a change in the Reviewing Pane while editing the header goes to the change in the body', async ({ page }) => { + const errors = await trackedDocument(page); + await page.getByRole('button', { name: 'Reviewing Pane', exact: true }).filter({ visible: true }).first().click(); + const items = page.getByRole('complementary', { name: 'Reviewing Pane' }).locator('.review-pane-item'); + await expect(items).toHaveCount(1); + await items.first().click(); + await expect(input(page)).toHaveAttribute('aria-label', 'Document text'); + await expect.poll(() => input(page).evaluate(el => el.value.slice(el.selectionStart, el.selectionEnd))).toBe('new '); + expect(errors).toEqual([]); +}); From 58495715b301a53c224bb201813eb954452b83d0 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:28:52 -0700 Subject: [PATCH 005/186] Documents: Moving paragraphs with the keyboard no longer slips past Track Changes Alt+Shift+Up/Down (Control+Shift on a Mac) reordered paragraphs untracked while Track Changes was on, so reviewers never saw the move and Reject All could not undo it. Like Sort and outline moves, it now says the move cannot be tracked. --- src/documents/docs/conformance.md | 2 ++ src/documents/src/engine/home-editing.js | 2 ++ src/documents/tests/home-editing.test.js | 10 ++++++++++ 3 files changed, 14 insertions(+) diff --git a/src/documents/docs/conformance.md b/src/documents/docs/conformance.md index 913bae624..15e257bf3 100644 --- a/src/documents/docs/conformance.md +++ b/src/documents/docs/conformance.md @@ -22133,6 +22133,8 @@ Supported subset: - Inserting a footnote or endnote reference (§17.11.14 `footnoteReference`, §17.11.7 `endnoteReference`) records the reference run as an insertion, written inside `w:ins`. - Replace All records each replacement as a deletion of the found text and an insertion. +- Reordering paragraphs (Sort, moving paragraphs with Alt+Shift+Up/Down or in the outline) cannot be recorded + and is refused while Track Changes is on. - A caption, cross-reference, citation, bibliography or table of contents inserted with Track Changes on is recorded as a tracked insertion (`w:ins`, with its paragraph marks) when the command inserts one stretch of text. A command that also renumbers or rewrites other text (a caption before existing ones, an IEEE citation, diff --git a/src/documents/src/engine/home-editing.js b/src/documents/src/engine/home-editing.js index 8ee8ef712..9c7180bc3 100644 --- a/src/documents/src/engine/home-editing.js +++ b/src/documents/src/engine/home-editing.js @@ -269,6 +269,8 @@ export function moveParagraphs(model, direction) { if (direction > 0 && last + 1 === model.paragraphs.length - 1 && !paragraphText(neighbour).length) return false; // A range between the moved paragraphs and the one they pass would end before it starts once they swap. if (rangeEdges([...targets, neighbour]).some(({ start, end }) => start && end && (start === neighbour) !== (end === neighbour))) return false; + // A reorder is not a change Track Changes can show here; done untracked, reviewers would never see it (as Sort). + if (model.trackChanges) throw new Error('Moving paragraphs cannot be tracked. Turn off Track Changes to move them.'); const [anchor, focus] = [model.selection.anchor, model.selection.focus]; const shift = (paragraphText(neighbour).length + 1) * (direction < 0 ? -1 : 1); model.checkpoint(); diff --git a/src/documents/tests/home-editing.test.js b/src/documents/tests/home-editing.test.js index 180dea069..18abc4d47 100644 --- a/src/documents/tests/home-editing.test.js +++ b/src/documents/tests/home-editing.test.js @@ -177,6 +177,16 @@ describe('Move paragraphs', () => { const reopened = await roundTrip(pkg, doc); expect(reopened.paragraphs.map(paragraphText)).toEqual(['one', 'four', 'two', 'three']); }); + it('refuses to move paragraphs untracked while Track Changes is on, as Sort does', async () => { + const { doc } = await styledDocument(['one', 'two', 'three']); + doc.trackChanges = true; + doc.select(5); + expect(() => moveParagraphs(doc, -1)).toThrow(/cannot be tracked/); + expect(texts(doc)).toEqual(['one', 'two', 'three']); + doc.trackChanges = false; + expect(moveParagraphs(doc, -1)).toBe(true); + expect(texts(doc)).toEqual(['two', 'one', 'three']); + }); it('does not move into or out of a table', async () => { const { doc } = await styledDocument(['above']); doc.insertTable(1, 1, 600); From 18ae99427c5daf9f1c742705991fc9167a52a96c Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:32:09 -0700 Subject: [PATCH 006/186] Documents: The status bar keeps counting the document's words while a header is edited Editing a header, footer or note, the status bar counted only that story's words ("2 words") while the Word Count dialog it opens counted the document's. It now counts the document's words, and words selected in the header against them. --- src/documents/src/views/editor.js | 11 +++++----- .../tests/browser/status-word-count.spec.js | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/documents/src/views/editor.js b/src/documents/src/views/editor.js index b52c540fb..98029c132 100644 --- a/src/documents/src/views/editor.js +++ b/src/documents/src/views/editor.js @@ -1491,12 +1491,13 @@ export function editorView({ doc, goHome, newDoc, importFile, openPath, newFromT // (twice) refresh the status bar, and splitting a long document into words takes milliseconds each time. let counted = { text: null, a: 0, b: 0, total: 0, chosen: null }; function updateWordCount() { - const text = engine.model.text, [a, b] = engine.model.range; - if (text !== counted.text || a !== counted.a || b !== counted.b) { - // The Word Count dialog counts with the same rule, so the two always agree. + // The document's words, also while a header, footer or note is edited (the words selected there are counted + // against them): the Word Count dialog counts with the same rule, so the two always agree. + const text = engine.session.body.text, story = engine.model.text, [a, b] = engine.model.range; + if (text !== counted.text || story !== counted.story || a !== counted.a || b !== counted.b) { const total = text === counted.text ? counted.total : countWords(text); - const chosen = a !== b ? countWords(text.slice(a, b)) : null; - counted = { text, a, b, total, chosen }; + const chosen = a !== b ? countWords(story.slice(a, b)) : null; + counted = { text, story, a, b, total, chosen }; } const { total, chosen } = counted; words.textContent = chosen !== null ? `${chosen} of ${total} ${total === 1 ? 'word' : 'words'}` : `${total} ${total === 1 ? 'word' : 'words'}`; diff --git a/src/documents/tests/browser/status-word-count.spec.js b/src/documents/tests/browser/status-word-count.spec.js index 3dfac20d2..9ae687054 100644 --- a/src/documents/tests/browser/status-word-count.spec.js +++ b/src/documents/tests/browser/status-word-count.spec.js @@ -81,3 +81,25 @@ test('at 320 pixels wide the status bar shows the whole word count instead of cu }); expect(problems).toEqual([]); }); + +test('while a header is edited the status bar counts the document\'s words, as the Word Count dialog does', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/mock.html#/new'); + await expect(page.getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + const count = page.locator('.ed-statusbar .ed-word-count'); + await text(page).fill('Alpha beta gamma delta.\nSecond paragraph here.'); + await expect(count).toHaveText('7 words'); + await page.getByRole('tab', { name: 'Insert' }).click(); + await page.locator('#ribbon-panel-insert').getByRole('button', { name: 'Header', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Edit Header' }).click(); + const header = page.getByRole('textbox', { name: 'Header text' }); + await expect(header).toBeFocused(); + await page.keyboard.type('Head note'); + await expect(header).toHaveValue('Head note'); + await expect(count).toHaveText('7 words'); + // Words selected in the header are counted against the document's. + await header.evaluate(el => { el.setSelectionRange(0, 4); el.dispatchEvent(new Event('select')); }); + await expect(count).toHaveText('1 of 7 words'); + await count.click(); + await expect(page.getByRole('dialog', { name: 'Word Count' }).locator('tr').filter({ hasText: 'Words' })).toContainText('7'); +}); From 35e381f290c84244e4a55fa147b3719a426b5357 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:36:04 -0700 Subject: [PATCH 007/186] Documents: Each document opens with its own Show Markup choices A document saved with insertions, deletions or formatting changes hidden (revisionView) hid them, and the next document opened kept them hidden although its settings show them, as it did a choice made in Show Markup. Opening a document now applies its own settings either way. --- src/documents/src/views/review-tools.js | 3 +- .../tests/browser/markup-settings.spec.js | 45 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 src/documents/tests/browser/markup-settings.spec.js diff --git a/src/documents/src/views/review-tools.js b/src/documents/src/views/review-tools.js index 5bd36dc95..1efd4a213 100644 --- a/src/documents/src/views/review-tools.js +++ b/src/documents/src/views/review-tools.js @@ -252,7 +252,8 @@ export function createReviewTools({ editor, pkg, error, author, review, proofing if (!engine.overlays.includes(editorPane.overlay)) engine.overlays.push(editorPane.overlay); applyTrackingSettings(); const view = docRevisionView(); - if (view.insDel === false || view.formatting === false) Object.assign(revisionView.options, { showInsDel: view.insDel !== false, showFormatting: view.formatting !== false }); + // The document's own choices, shown or not: those of the document opened before it would otherwise stay. + Object.assign(revisionView.options, { showInsDel: view.insDel !== false, showFormatting: view.formatting !== false }); restrict.applyGuard(); // A protected document opens with its permissions in view, as desktop word processors show them. const protection = pkg()?.reviewSettings?.protection; diff --git a/src/documents/tests/browser/markup-settings.spec.js b/src/documents/tests/browser/markup-settings.spec.js new file mode 100644 index 000000000..c7d18f86d --- /dev/null +++ b/src/documents/tests/browser/markup-settings.spec.js @@ -0,0 +1,45 @@ +import { test, expect } from '@playwright/test'; +import { zipSync, strToU8 } from 'fflate'; + +const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; +/** A document with a tracked insertion and a tracked deletion; `view` is its settings' revisionView element. */ +function trackedDocx(view = '') { + const files = { + '[Content_Types].xml': '', + '_rels/.rels': '', + 'word/_rels/document.xml.rels': '', + 'word/settings.xml': `${view}`, + 'word/document.xml': `Keep added removed end.`, + }; + return Buffer.from(zipSync(Object.fromEntries(Object.entries(files).map(([k, v]) => [k, strToU8(v)])))); +} +async function open(page, name, buffer) { + page.once('dialog', dialog => dialog.accept()); + await page.getByLabel('Open DOCX file').setInputFiles({ name, mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', buffer }); + await expect(page.getByRole('textbox', { name: 'Document text' })).toHaveValue('Keep added removed end.'); +} +async function shown(page, name) { + const tab = page.getByRole('tab', { name: 'Review' }); + if (await tab.getAttribute('aria-selected') !== 'true') await tab.click(); + await page.locator('#ribbon-panel-review').getByRole('button', { name: 'Show Markup', exact: true }).click(); + const item = page.getByRole('menuitemcheckbox', { name, exact: true }); + const checked = await item.getAttribute('aria-checked'); + await page.keyboard.press('Escape'); + return checked === 'true'; +} + +test('a document opens with its own Show Markup choices, not those of the document opened before it', async ({ page }) => { + const errors = []; page.on('pageerror', e => errors.push(e.message)); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/playground.html'); + await page.getByRole('textbox', { name: 'Document text' }).waitFor(); + await open(page, 'hidden.docx', trackedDocx('')); + expect(await shown(page, 'Insertions and Deletions')).toBe(false); + expect(await shown(page, 'Formatting')).toBe(false); + await open(page, 'shown.docx', trackedDocx()); + expect(await shown(page, 'Insertions and Deletions')).toBe(true); + expect(await shown(page, 'Formatting')).toBe(true); + // Its deletion is struck through on the page, so the balloons and bars list it. + await expect(page.locator('#ribbon-panel-review').getByRole('button', { name: 'Accept options' })).toBeEnabled(); + expect(errors).toEqual([]); +}); From 7d9471fd71e13658b82e3f7cdf700bb2f12b9371 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:36:45 -0700 Subject: [PATCH 008/186] Documents: Next and Previous skip the changes and comments of reviewers hidden in Show Markup With a reviewer hidden (Show Markup > Specific People), Next Change and Next Comment still stopped at that reviewer's changes and comments, selecting text with no mark or card to show for it. They now pass over what Show Markup hides. --- src/documents/src/views/review.js | 6 +++-- src/documents/tests/review-navigation.test.js | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/documents/src/views/review.js b/src/documents/src/views/review.js index f9b75c9d2..cddcd9f1e 100644 --- a/src/documents/src/views/review.js +++ b/src/documents/src/views/review.js @@ -202,7 +202,8 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = }, go(direction) { const engine = editor(); if (!engine) return; - const list = threads().filter(t => t.start !== null); + // The comments of reviewers hidden in Show Markup have no card to go to. + const list = threads().filter(t => t.start !== null && !revisionView.options.hiddenAuthors.includes(t.root.author)); if (!list.length) return; const [a, b] = engine.model.range, current = list.findIndex(t => t.id === activeId); let next; @@ -278,7 +279,8 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = }, goChange(direction, { from } = {}) { const engine = editor(); if (!engine) return; - const body = engine.session.body, list = body.revisions; + // Changes hidden in Show Markup (a reviewer's, or a kind of change) are passed over, as their marks are. + const body = engine.session.body, list = shownChanges(body.revisions); if (!list.length) return; const [a, b] = body.range, at = from ?? (direction > 0 ? b : a), length = body.text.length; // From a selected change, step through the list: a paragraph's formatting change spans the changes inside the diff --git a/src/documents/tests/review-navigation.test.js b/src/documents/tests/review-navigation.test.js index 1816e6c74..765fb9fc2 100644 --- a/src/documents/tests/review-navigation.test.js +++ b/src/documents/tests/review-navigation.test.js @@ -3,6 +3,7 @@ import { beforeEach, afterEach, it, expect, vi } from 'vitest'; import { CanvasEditor } from '../src/engine/canvas-editor.js'; import { paragraph } from '../src/engine/model.js'; import { createReview } from '../src/views/review.js'; +import { revisionView } from '../src/engine/revisions.js'; let editor, host, review; beforeEach(() => { @@ -36,3 +37,29 @@ it('Next and Previous Change visit the changes inside a reformatted paragraph an for (let i = 0; i < 3; i++) { review.commands.goChange(-1); back.push(selected()); } expect(back).toEqual(['new ', 'Second new line here.\n', 'new ']); }); + +it('Next and Previous pass over the changes and comments of reviewers hidden in Show Markup', () => { + open(['First line.', 'Second line.', 'Third line.']); + editor.model.trackChanges = true; + for (const [author, at, text] of [['Ada', 0, 'A '], ['Grace', 14, 'G '], ['Ada', 28, 'B ']]) { + editor.model.revisionAuthor = author; + editor.execute(() => { editor.model.select(at); editor.model.replace(text); }); + } + editor.model.trackChanges = false; + for (const [author, at] of [['Ada', 2], ['Grace', 16], ['Ada', 30]]) editor.model.addComment({ author, text: author, range: [at, at + 4] }); + revisionView.options.hiddenAuthors = ['Grace']; + try { + review.refresh(true); + editor.model.select(0, 0); + const changes = []; + for (let i = 0; i < 3; i++) { review.commands.goChange(1); changes.push(selected()); } + expect(changes).toEqual(['A ', 'B ', 'A ']); + review.commands.goChange(-1); + expect(selected()).toBe('B '); + editor.model.select(0, 0); + const comments = []; + for (let i = 0; i < 3; i++) { review.commands.go(1); comments.push(review.commands.activeThread().root.author); } + expect(comments).toEqual(['Ada', 'Ada', 'Ada']); + expect(review.commands.activeThread().start).toBe(2); + } finally { revisionView.options.hiddenAuthors = []; } +}); From c9faea297c277974beeda4ff94199679ebf55fd3 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:38:13 -0700 Subject: [PATCH 009/186] Documents: Comment cards no longer pile up at the top of the column Choosing a comment keeps its card level with its text and moves the cards before it up to make room. Near the top of the document there is no room, and those cards were all drawn at the top, one over another, so all but one could not be read or clicked. They now stack down from the top, and the chosen card moves down below them. --- src/documents/src/views/review.js | 9 ++++++--- src/documents/tests/comment-column.test.js | 13 +++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/documents/src/views/review.js b/src/documents/src/views/review.js index cddcd9f1e..d8c851cd3 100644 --- a/src/documents/src/views/review.js +++ b/src/documents/src/views/review.js @@ -472,10 +472,13 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = if (activeIndex >= 0) { for (let i = activeIndex + 1; i < anchored.length; i++) tops[i] = Math.max(tops[i], tops[i - 1] + heights[i - 1] + gap); for (let i = activeIndex - 1; i >= 0; i--) tops[i] = Math.min(tops[i], tops[i + 1] - heights[i] - gap); - for (let i = 1; i < anchored.length; i++) tops[i] = Math.max(tops[i], tops[i - 1] + heights[i - 1] + gap); - } else for (let i = 1; i < anchored.length; i++) tops[i] = Math.max(tops[i], tops[i - 1] + heights[i - 1] + gap); + } + // The column starts at the top of the pages: cards with no room above the active one stack down from there + // (and push it down) instead of all being drawn at the top, one over another. + if (anchored.length) tops[0] = Math.max(8, tops[0]); + for (let i = 1; i < anchored.length; i++) tops[i] = Math.max(tops[i], tops[i - 1] + heights[i - 1] + gap); anchored.forEach((item, i) => { - item.node.style.top = `${Math.max(8, tops[i])}px`; + item.node.style.top = `${tops[i]}px`; item.node.style.left = `${right + 16}px`; item.node.style.display = ''; }); diff --git a/src/documents/tests/comment-column.test.js b/src/documents/tests/comment-column.test.js index 51ab8edac..1cc889c3d 100644 --- a/src/documents/tests/comment-column.test.js +++ b/src/documents/tests/comment-column.test.js @@ -48,3 +48,16 @@ it('keeps a reply and an edit being written when the column is rebuilt around th card(second).querySelector('.wcc-entry.is-editing .wcc-cancel').click(); expect(editor.model.comments.find(c => c.id === second).text).toBe('Two'); }); + +it('stacks the cards above the active one down from the top instead of piling them up there', () => { + open(['One two three four five six seven.', 'Second paragraph.']); + const ids = [[0, 3], [4, 7], [8, 13], [14, 18], [19, 23]].map(([a, b], i) => editor.model.addComment({ author: 'Ada', text: `c${i}`, range: [a, b] })); + editor.relayout(); + review.commands.selectThreadById(ids[4]); + review.refresh(true); + const tops = ids.map(id => parseFloat(card(id).style.top)); + expect(card(ids[4]).classList.contains('is-active')).toBe(true); + // Each card starts below the one before it (a card is 80 pixels high where nothing is laid out). + for (let i = 1; i < tops.length; i++) expect(tops[i], `card ${i}`).toBeGreaterThanOrEqual(tops[i - 1] + 88); + expect(tops[0]).toBeGreaterThanOrEqual(8); +}); From 936a4d8164fb84d4544c8712ddd9cafce4980e5c Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:40:01 -0700 Subject: [PATCH 010/186] Documents: A comment the document refuses stays in its box instead of being thrown away Posting a new comment, a reply or an edit that the document refused (a protected document, a comment over 32,000 characters) showed the error and discarded the words typed. They now stay in the draft, reply or edit box, focused, to post again or cancel. --- src/documents/src/views/review.js | 22 ++++++++++---- src/documents/tests/comment-column.test.js | 34 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/documents/src/views/review.js b/src/documents/src/views/review.js index d8c851cd3..a1eb140e7 100644 --- a/src/documents/src/views/review.js +++ b/src/documents/src/views/review.js @@ -318,8 +318,13 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = } function textEditor({ value = '', placeholder, submitLabel, onSubmit, onCancel, key = null }) { if (key && unsent.has(key)) value = unsent.get(key); - if (key) { const submitted = onSubmit, cancelled = onCancel; onSubmit = text => { unsent.delete(key); submitted(text); }; onCancel = () => { unsent.delete(key); cancelled?.(); }; } - const input = el('textarea.wcc-input', { rows: 1, placeholder: `${placeholder} (type @ to mention someone)`, 'aria-label': placeholder, 'aria-autocomplete': 'list' }, value); + if (key) { + const submitted = onSubmit, cancelled = onCancel; + // Words that could not be posted (the document refused the change) stay in their box, to try again. + onSubmit = text => { unsent.delete(key); if (!submitted(text)) { unsent.set(key, text); refresh(true); queueMicrotask(() => layer.querySelector(`textarea[data-key="${key}"]`)?.focus()); } }; + onCancel = () => { unsent.delete(key); cancelled?.(); }; + } + const input = el('textarea.wcc-input', { rows: 1, ...(key ? { dataset: { key } } : {}), placeholder: `${placeholder} (type @ to mention someone)`, 'aria-label': placeholder, 'aria-autocomplete': 'list' }, value); input.value = value; const grow = () => { input.style.height = 'auto'; input.style.height = `${Math.min(200, input.scrollHeight + 2)}px`; }; const submit = el('button.btn.btn-primary.btn-sm.wcc-post', { type: 'button', disabled: !value.trim(), onclick: () => onSubmit(input.value) }, submitLabel); @@ -371,7 +376,7 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = const head = el('div.wcc-head', {}, avatar(comment.author, comment.initials), el('div.wcc-meta', {}, el('span.wcc-author', {}, comment.author || 'Unknown author'), el('span.wcc-date', { title: comment.date ? new Date(comment.date).toLocaleString() : '' }, time(comment.date))), more); const body = editing === comment.id - ? textEditor({ value: comment.text, key: `edit:${comment.id}`, placeholder: 'Edit comment', submitLabel: 'Save', onSubmit: text => { editing = null; run(() => editor().model.editComment(comment.id, text)); refresh(true); }, onCancel: () => { editing = null; refresh(true); editor()?.focus(); } }) + ? textEditor({ value: comment.text, key: `edit:${comment.id}`, placeholder: 'Edit comment', submitLabel: 'Save', onSubmit: text => { editing = null; const ok = run(() => editor().model.editComment(comment.id, text)); if (!ok) editing = comment.id; refresh(true); return ok; }, onCancel: () => { editing = null; refresh(true); editor()?.focus(); } }) // Only text with an @ can mention anyone. : el('div.wcc-text', {}, ...renderMentions(comment.text, String(comment.text || '').includes('@') ? names() : [])); return el(`div.wcc-entry${editing === comment.id ? '.is-editing' : ''}`, { dataset: { commentId: comment.id } }, head, body); @@ -389,7 +394,7 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = node.append(entry(thread.root, thread, true, names), ...thread.replies.map(reply => entry(reply, thread, false, names))); if (done) node.append(el('div.wcc-resolved', {}, el('span', {}, 'Resolved'), el('button.btn.btn-sm', { type: 'button', disabled: !ready(), onclick: () => { run(() => editor().model.setCommentDone(thread.id, false)); refresh(true); } }, 'Reopen'))); else if (ready()) node.append(el('div.wcc-reply', {}, textEditor({ key: `reply:${thread.id}`, placeholder: 'Reply', submitLabel: 'Reply', - onSubmit: text => { run(() => editor().model.replyToComment(thread.id, { ...me(), text })); refresh(true); }, + onSubmit: text => { const ok = run(() => editor().model.replyToComment(thread.id, { ...me(), text })); refresh(true); return ok; }, onCancel: () => { activeId = null; pinned = false; refresh(true); editor()?.focus(); } })), el('button.btn.btn-sm.wcc-resolve', { type: 'button', title: 'Resolve thread', onclick: () => { run(() => editor().model.setCommentDone(thread.id, true)); refresh(true); } }, 'Resolve')); return node; @@ -421,9 +426,14 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = const editorNode = textEditor({ value: draft.value || '', placeholder: 'Start a conversation', submitLabel: 'Post', onSubmit: text => { const engine = editor(); if (engine) followDraft(engine); - const { start, end } = draft; draft = null; + const posting = draft, { start, end } = draft; draft = null; let id = null; - run(() => { id = editor().model.addComment({ ...me(), text, range: [start, end] }); }); + if (!run(() => { id = editor().model.addComment({ ...me(), text, range: [start, end] }); })) { + // The document refused it (protection, a comment too long): the words stay in the draft to try again. + draft = Object.assign(posting, { value: text }); activeId = 'draft'; refresh(true); + queueMicrotask(() => layer.querySelector('.ed-comment-draft textarea')?.focus()); + return; + } activeId = id; pinned = true; pinnedRange = editor()?.model.range.join() ?? ''; refresh(true); editor()?.focus(); }, onCancel: () => { draft = null; activeId = null; refresh(true); editor()?.focus(); } }); diff --git a/src/documents/tests/comment-column.test.js b/src/documents/tests/comment-column.test.js index 1cc889c3d..323b9b5c1 100644 --- a/src/documents/tests/comment-column.test.js +++ b/src/documents/tests/comment-column.test.js @@ -61,3 +61,37 @@ it('stacks the cards above the active one down from the top instead of piling th for (let i = 1; i < tops.length; i++) expect(tops[i], `card ${i}`).toBeGreaterThanOrEqual(tops[i - 1] + 88); expect(tops[0]).toBeGreaterThanOrEqual(8); }); + +it('keeps a new comment, a reply or an edit the document refuses, to post again', async () => { + open(['First paragraph here.', 'Second paragraph here.']); + const first = editor.model.addComment({ author: 'Ada', text: 'One', range: [0, 5] }); + review.refresh(true); + // Protection that allows no comment changes. + editor.session.guard = () => { throw new Error('This document is protected: comments cannot be changed.'); }; + editor.model.select(22, 28); + review.commands.newComment(); + type(review.layer.querySelector('.ed-comment-draft textarea'), 'A long thought'); + review.layer.querySelector('.ed-comment-draft .wcc-post').click(); + expect(editor.model.comments).toHaveLength(1); + expect(review.layer.querySelector('.ed-comment-draft textarea').value).toBe('A long thought'); + await Promise.resolve(); + expect(document.activeElement).toBe(review.layer.querySelector('.ed-comment-draft textarea')); + review.layer.querySelector('.ed-comment-draft .wcc-cancel').click(); + review.commands.selectThreadById(first); + type(card(first).querySelector('.wcc-reply textarea'), 'A reply'); + card(first).querySelector('.wcc-reply .wcc-post').click(); + expect(editor.model.comments).toHaveLength(1); + expect(card(first).querySelector('.wcc-reply textarea').value).toBe('A reply'); + card(first).querySelector('.wcc-more').click(); + document.querySelector('[role="menuitem"]').click(); + type(card(first).querySelector('.wcc-entry.is-editing textarea'), 'One, edited'); + card(first).querySelector('.wcc-entry.is-editing .wcc-post').click(); + expect(editor.model.comments[0].text).toBe('One'); + expect(card(first).querySelector('.wcc-entry.is-editing textarea').value).toBe('One, edited'); + // Allowed again, they post. + editor.session.guard = null; + card(first).querySelector('.wcc-entry.is-editing .wcc-post').click(); + card(first).querySelector('.wcc-reply .wcc-post').click(); + expect(editor.model.comments.map(c => c.text)).toEqual(['One, edited', 'A reply']); + expect(card(first).querySelector('.wcc-reply textarea').value).toBe(''); +}); From b2a83aa27d055134fe0a30d1047c01c89479140a Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:41:10 -0700 Subject: [PATCH 011/186] Documents: Comments can't be started or changed where document protection allows none In a document protected as read-only or for filling in forms (or opened read-only as its author recommends), New Comment, Reply, Resolve, Edit and Delete were offered, and what was written was refused only when posted. They are now unavailable there, and the New Comment shortcut says the document is protected. --- src/documents/src/views/editor.js | 2 +- src/documents/src/views/review-tools.js | 2 ++ src/documents/src/views/review.js | 27 +++++++++++++--------- src/documents/tests/comment-column.test.js | 23 ++++++++++++++++++ 4 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/documents/src/views/editor.js b/src/documents/src/views/editor.js index 98029c132..178b6bb0b 100644 --- a/src/documents/src/views/editor.js +++ b/src/documents/src/views/editor.js @@ -999,7 +999,7 @@ export function editorView({ doc, goHome, newDoc, importFile, openPath, newFromT currentBytes: async () => new Uint8Array(await (pkg.readOnly ? doc.blob : engine.export(pkg)).arrayBuffer()), onChange: () => update() }); // Under protection (other than Lock Tracking) the Mailings commands, which rewrite the document and its settings, are unavailable. mailingHooks.protectedDocument = () => reviewTools.protectionEnforced && !reviewTools.trackingLocked; - Object.assign(reviewHooks, { tints: reviewTools.tints, trackingLocked: () => reviewTools.trackingLocked, people: () => reviewTools.people(), commentsPane: reviewTools.commands.commentsPane, trackingOptions: reviewTools.commands.trackingOptions, showMarkupItems: reviewTools.commands.showMarkupItems, lockTracking: reviewTools.commands.lockTracking }); + Object.assign(reviewHooks, { tints: reviewTools.tints, trackingLocked: () => reviewTools.trackingLocked, protection: () => reviewTools.protection, people: () => reviewTools.people(), commentsPane: reviewTools.commands.commentsPane, trackingOptions: reviewTools.commands.trackingOptions, showMarkupItems: reviewTools.commands.showMarkupItems, lockTracking: reviewTools.commands.lockTracking }); Object.assign(editorCommands, reviewTools.commands); const reviewFileItems = editorCommands.fileItems; editorCommands.fileItems = () => [...(reviewFileItems?.() || []), ...reviewTools.fileItems()]; diff --git a/src/documents/src/views/review-tools.js b/src/documents/src/views/review-tools.js index 1efd4a213..9a07b4dde 100644 --- a/src/documents/src/views/review-tools.js +++ b/src/documents/src/views/review-tools.js @@ -265,6 +265,8 @@ export function createReviewTools({ editor, pkg, error, author, review, proofing selectionChanged() { editorPane.selectionChanged(); translator.selectionChanged(); }, get trackingLocked() { return restrict.trackingLocked; }, get protectionEnforced() { return restrict.enforced; }, + /** The protection in force, or null. */ + get protection() { return restrict.enforced ? restrict.protection : null; }, destroy() { keyAbort.abort(); for (const part of [editorPane, thesaurus, accessibility, translator, commentsPane, restrict]) part.destroy(); }, }; } diff --git a/src/documents/src/views/review.js b/src/documents/src/views/review.js index a1eb140e7..1d30623d4 100644 --- a/src/documents/src/views/review.js +++ b/src/documents/src/views/review.js @@ -34,7 +34,7 @@ export function commentThreads(list) { } /** - * hooks (filled in by the review tools): tints() for extra range tints, trackingLocked(), + * hooks (filled in by the review tools): tints() for extra range tints, trackingLocked(), protection(), * people() for @mentions, commentsPane(), trackingOptions(), showMarkupItems(), lockTracking(). */ export function createReview({ editor, pkg, host, error, author, onChange = () => {}, onShift = () => {}, hooks = {} }) { @@ -131,6 +131,10 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = /** Changes shown under the current Show Markup choices (reviewers, kinds). */ const shownChanges = list => list.filter(c => !revisionView.options.hiddenAuthors.includes(c.author) && (c.type === 'format' ? revisionView.options.showFormatting : revisionView.options.showInsDel)); const locked = () => !!hooks.trackingLocked?.(); + // Document protection that allows no comments: read only, or filling in forms. + const commentsRefused = () => ['readOnly', 'forms'].includes(hooks.protection?.()?.edit); + // Whether comments can be replied to, edited, resolved and deleted. + const annotating = () => !!ready() && !commentsRefused(); const ready = () => { const engine = editor(); return engine && !pkg()?.readOnly && !engine.viewOnly && engine.model.comments ? engine : null; }; const me = () => { const name = author() || 'Author'; return { author: name, initials: initialsFor(name) }; }; /** @@ -179,10 +183,11 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = } const commands = { - canComment() { const engine = ready(); return !!engine && engine.session.path === null; }, + canComment() { const engine = ready(); return !!engine && engine.session.path === null && !commentsRefused(); }, newComment() { const engine = ready(); if (!engine) return; if (engine.session.path !== null) { error(new Error('Comments can be added to the document body.')); return; } + if (commentsRefused()) { error(new Error('This document is protected: comments cannot be added.')); return; } engine.finishComposition(); const [start, end] = engine.model.commentRange(); commentsShown = true; @@ -366,7 +371,7 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = return el('div.wcc-editor', {}, input, mentions, el('div.wcc-actions', {}, cancel, submit)); } function entry(comment, thread, isRoot, names) { - const editable = !!ready(); + const editable = annotating(); const more = el('button.icon-btn.wcc-more', { type: 'button', 'aria-label': `More thread actions for ${comment.author || 'comment'}`, title: 'More thread actions', disabled: !editable, onclick: () => openMenu({ anchor: more, label: 'Comment actions', items: [ { label: 'Edit comment', onSelect: () => { editing = comment.id; activeId = thread.id; pinned = true; pinnedRange = editor()?.model.range.join() ?? ''; refresh(true); queueMicrotask(() => cards.get(thread.id)?.querySelector('.wcc-entry.is-editing textarea')?.focus()); } }, @@ -392,8 +397,8 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = return node; } node.append(entry(thread.root, thread, true, names), ...thread.replies.map(reply => entry(reply, thread, false, names))); - if (done) node.append(el('div.wcc-resolved', {}, el('span', {}, 'Resolved'), el('button.btn.btn-sm', { type: 'button', disabled: !ready(), onclick: () => { run(() => editor().model.setCommentDone(thread.id, false)); refresh(true); } }, 'Reopen'))); - else if (ready()) node.append(el('div.wcc-reply', {}, textEditor({ key: `reply:${thread.id}`, placeholder: 'Reply', submitLabel: 'Reply', + if (done) node.append(el('div.wcc-resolved', {}, el('span', {}, 'Resolved'), el('button.btn.btn-sm', { type: 'button', disabled: !annotating(), onclick: () => { run(() => editor().model.setCommentDone(thread.id, false)); refresh(true); } }, 'Reopen'))); + else if (annotating()) node.append(el('div.wcc-reply', {}, textEditor({ key: `reply:${thread.id}`, placeholder: 'Reply', submitLabel: 'Reply', onSubmit: text => { const ok = run(() => editor().model.replyToComment(thread.id, { ...me(), text })); refresh(true); return ok; }, onCancel: () => { activeId = null; pinned = false; refresh(true); editor()?.focus(); } })), el('button.btn.btn-sm.wcc-resolve', { type: 'button', title: 'Resolve thread', onclick: () => { run(() => editor().model.setCommentDone(thread.id, true)); refresh(true); } }, 'Resolve')); @@ -531,7 +536,7 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = layer.hidden = !visible; // Where the threads are anchored does not change the cards (place() follows the anchors), so typing before // a comment does not rebuild every card. - const sig = JSON.stringify([JSON.stringify(list, (key, value) => key === 'start' || key === 'end' ? undefined : value), activeId, editing, !!draft, commentsShown, !!ready(), balloons, locked()]); + const sig = JSON.stringify([JSON.stringify(list, (key, value) => key === 'start' || key === 'end' ? undefined : value), activeId, editing, !!draft, commentsShown, !!ready(), annotating(), balloons, locked()]); if (force || sig !== signature) { signature = sig; const focused = layer.contains(document.activeElement) ? document.activeElement : null; @@ -593,10 +598,10 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = ] }) }); for (const b of [add, remove, previous, next, show, resolve]) b.addEventListener('mousedown', e => e.preventDefault()); when(add, () => commands.canComment()); - when(remove, () => !!ready() && !!(commands.activeThread() || threadAtCaret())); - when(removeSplit.arrow, () => !!ready() && threads().length > 0); + when(remove, () => annotating() && !!(commands.activeThread() || threadAtCaret())); + when(removeSplit.arrow, () => annotating() && threads().length > 0); when(previous, () => threads().length > 0); when(next, () => threads().length > 0); - when(resolve, () => !!ready() && !!(commands.activeThread() || threadAtCaret())); + when(resolve, () => annotating() && !!(commands.activeThread() || threadAtCaret())); when(show, () => !!editor()?.model.comments, () => String(commentsShown)); when(showSplit.arrow, () => !!editor()?.model.comments); const track = command('Track Changes', 'trackChanges', () => commands.toggleTracking(), { size: 'large', text: 'Track Changes', shortcut: `${MOD_KEY}+Shift+E` }); @@ -660,8 +665,8 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = if (!engine.model.comments) return changes; const thread = threadAtCaret(); return [...changes,{ label: 'New Comment', disabled: !commands.canComment(), onSelect: () => commands.newComment() }, - ...(thread ? [{ label: thread.root.done ? 'Reopen Comment' : 'Resolve Comment', disabled: !ready(), onSelect: () => { activeId = thread.id; commands.resolveActive(); } }, - { label: 'Delete Comment', disabled: !ready(), onSelect: () => { activeId = thread.id; commands.deleteActive(); } }] : [])]; + ...(thread ? [{ label: thread.root.done ? 'Reopen Comment' : 'Resolve Comment', disabled: !annotating(), onSelect: () => { activeId = thread.id; commands.resolveActive(); } }, + { label: 'Delete Comment', disabled: !annotating(), onSelect: () => { activeId = thread.id; commands.deleteActive(); } }] : [])]; } const keyAbort = new AbortController(); diff --git a/src/documents/tests/comment-column.test.js b/src/documents/tests/comment-column.test.js index 323b9b5c1..771403882 100644 --- a/src/documents/tests/comment-column.test.js +++ b/src/documents/tests/comment-column.test.js @@ -95,3 +95,26 @@ it('keeps a new comment, a reply or an edit the document refuses, to post again' expect(editor.model.comments.map(c => c.text)).toEqual(['One, edited', 'A reply']); expect(card(first).querySelector('.wcc-reply textarea').value).toBe(''); }); + +it('offers no new comment where the document\'s protection allows none', () => { + editor = new CanvasEditor(host, { paragraphs: [paragraph('Protected text.')], comments: [] }); + let protection = { edit: 'readOnly', enforcement: true }; + const errors = []; + review = createReview({ editor: () => editor, pkg: () => ({}), host: () => host, error: e => errors.push(e.message), author: () => 'Ada', hooks: { protection: () => protection } }); + editor.model.addComment({ author: 'Grace', text: 'Earlier', range: [10, 14] }); + review.refresh(true); + editor.model.select(0, 9); + expect(review.commands.canComment()).toBe(false); + // The thread already there cannot be answered, resolved or changed either. + expect(review.layer.querySelector('.wcc-reply')).toBeNull(); + expect(review.layer.querySelector('.wcc-more').disabled).toBe(true); + review.commands.newComment(); + expect(review.layer.querySelector('.ed-comment-draft')).toBeNull(); + expect(errors).toEqual(['This document is protected: comments cannot be added.']); + protection = { edit: 'forms', enforcement: true }; + expect(review.commands.canComment()).toBe(false); + // Protected for comments only, or not at all, a comment can be started. + for (protection of [{ edit: 'comments', enforcement: true }, null]) expect(review.commands.canComment()).toBe(true); + review.commands.newComment(); + expect(review.layer.querySelector('.ed-comment-draft')).not.toBeNull(); +}); From 21af1526043b4971e87833303c2e1931b50c5c4b Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:42:19 -0700 Subject: [PATCH 012/186] Documents: Track Changes can't be switched in a document protected for comments, forms or reading only Under such protection Track Changes could still be turned on or off, changing the protected document's settings (and marking it unsaved) though nothing else could change. It is now unavailable there, as it is while tracking is locked. --- src/documents/src/views/review.js | 7 ++++++- src/documents/tests/review-navigation.test.js | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/documents/src/views/review.js b/src/documents/src/views/review.js index 1d30623d4..bfc121e72 100644 --- a/src/documents/src/views/review.js +++ b/src/documents/src/views/review.js @@ -133,6 +133,9 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = const locked = () => !!hooks.trackingLocked?.(); // Document protection that allows no comments: read only, or filling in forms. const commentsRefused = () => ['readOnly', 'forms'].includes(hooks.protection?.()?.edit); + // Protection other than locked tracking keeps Track Changes as the document has it. + const trackingFixed = () => ['readOnly', 'comments', 'forms'].includes(hooks.protection?.()?.edit); + const fixedTracking = () => error(new Error('This document is protected: Track Changes cannot be turned on or off.')); // Whether comments can be replied to, edited, resolved and deleted. const annotating = () => !!ready() && !commentsRefused(); const ready = () => { const engine = editor(); return engine && !pkg()?.readOnly && !engine.viewOnly && engine.model.comments ? engine : null; }; @@ -236,16 +239,18 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = setTrackingMode(mode) { const engine = ready(); if (!engine) return; if (locked() && mode === 'off') { error(new Error('Tracking is locked. Unlock it to turn Track Changes off.')); return; } + if (trackingFixed()) { fixedTracking(); return; } engine.session.body.trackChangesMode = mode === 'mine' ? 'mine' : 'everyone'; commands.toggleTracking(mode !== 'off', { force: true }); }, /* ---- Tracked changes ---- */ get tracking() { return !!editor()?.session.body.trackChanges; }, - canTrack() { return !!ready(); }, + canTrack() { return !!ready() && !trackingFixed(); }, toggleTracking(on = !commands.tracking, { force = false } = {}) { const engine = ready(); if (!engine) return; engine.finishComposition(); if (!on && locked()) { error(new Error('Tracking is locked. Unlock it to turn Track Changes off.')); return; } + if (trackingFixed() && !!engine.session.body.trackChanges !== !!on) { fixedTracking(); return; } if (!!engine.session.body.trackChanges === !!on) { if (force) { engine.session.revision++; engine.onChange(); onChange(); } return; } engine.session.body.trackChanges = !!on; engine.session.revision++; diff --git a/src/documents/tests/review-navigation.test.js b/src/documents/tests/review-navigation.test.js index 765fb9fc2..9874ea06a 100644 --- a/src/documents/tests/review-navigation.test.js +++ b/src/documents/tests/review-navigation.test.js @@ -63,3 +63,22 @@ it('Next and Previous pass over the changes and comments of reviewers hidden in expect(review.commands.activeThread().start).toBe(2); } finally { revisionView.options.hiddenAuthors = []; } }); + +it('leaves Track Changes as the document has it while protection allows only comments, forms or no changes', () => { + editor = new CanvasEditor(host, { paragraphs: [paragraph('Protected text.')], comments: [] }); + let protection = { edit: 'comments', enforcement: true }; + const errors = []; + review = createReview({ editor: () => editor, pkg: () => ({}), host: () => host, error: e => errors.push(e.message), author: () => 'Ada', hooks: { protection: () => protection } }); + review.refresh(true); + const revision = editor.session.revision; + expect(review.commands.canTrack()).toBe(false); + review.commands.toggleTracking(); + review.commands.setTrackingMode('mine'); + expect(review.commands.tracking).toBe(false); + expect(editor.session.revision).toBe(revision); + expect(errors).toEqual(['This document is protected: Track Changes cannot be turned on or off.', 'This document is protected: Track Changes cannot be turned on or off.']); + protection = null; + expect(review.commands.canTrack()).toBe(true); + review.commands.toggleTracking(); + expect(review.commands.tracking).toBe(true); +}); From 578f67766d309898a9487ecd9a903d46f5378a50 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:45:22 -0700 Subject: [PATCH 013/186] Documents: Screen readers announce the person chosen in a comment's @mention list The suggestions offered after typing @ were not tied to the comment box, and moving through them with the arrow keys changed only their look, so a screen reader said nothing. The box now controls the list and names the chosen person as its active descendant; the suggestions are no longer separate Tab stops. --- src/documents/src/views/review.js | 28 +++++++++++++++------- src/documents/tests/comment-column.test.js | 23 ++++++++++++++++++ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/documents/src/views/review.js b/src/documents/src/views/review.js index bfc121e72..3caade296 100644 --- a/src/documents/src/views/review.js +++ b/src/documents/src/views/review.js @@ -14,7 +14,7 @@ export const MARKUP_VIEWS = [['simple', 'Simple Markup'], ['all', 'All Markup'], const AVATAR_COLORS = ['#3B6FB6', '#2E8A6E', '#B5523B', '#7B5AA6', '#A3762A', '#2F7F95', '#A04A73', '#5F7A2E']; const TINT = 'rgba(128,128,128,.16)', ACTIVE_TINT = 'rgba(240,188,70,.42)'; -let commentsShown = true; +let commentsShown = true, mentionLists = 0; export function avatarColor(name) { let hash = 0; @@ -340,8 +340,17 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = const submit = el('button.btn.btn-primary.btn-sm.wcc-post', { type: 'button', disabled: !value.trim(), onclick: () => onSubmit(input.value) }, submitLabel); const cancel = el('button.btn.btn-sm.wcc-cancel', { type: 'button', onclick: () => onCancel?.() }, 'Cancel'); // @mentions: names of the people taking part in the review, offered as the name is typed. - const mentions = el('div.wcc-mentions', { role: 'listbox', 'aria-label': 'People', hidden: true }); + // The suggestions belong to the text box, which keeps the keyboard: the one chosen with the arrow keys is its + // active descendant, so a screen reader says it. + const listId = `wcc-mentions-${++mentionLists}`; + const mentions = el('div.wcc-mentions', { id: listId, role: 'listbox', 'aria-label': 'People', hidden: true }); + input.setAttribute('aria-controls', listId); let mention = null, chosen = 0; + const choose = index => { + chosen = index; + [...mentions.children].forEach((b, i) => b.setAttribute('aria-selected', String(i === chosen))); + if (mention && !mentions.hidden) input.setAttribute('aria-activedescendant', `${listId}-${chosen}`); else input.removeAttribute('aria-activedescendant'); + }; const query = () => { const before = input.value.slice(0, input.selectionStart), match = /(^|[\s(])@([\p{L}\p{N} .'-]{0,30})$/u.exec(before); if (!match) return null; @@ -349,25 +358,26 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = return names.length && !names.some(n => n.toLowerCase() === typed) ? { start: before.length - match[2].length - 1, names: names.slice(0, 6) } : null; }; const showMentions = () => { - mention = query(); chosen = 0; + mention = query(); mentions.hidden = !mention; - if (!mention) return; - mentions.replaceChildren(...mention.names.map((name, i) => el('button.wcc-mention', { type: 'button', role: 'option', 'aria-selected': String(i === chosen), onmousedown: e => e.preventDefault(), onclick: () => pick(name) }, el('span.wcc-avatar', { 'aria-hidden': 'true', style: { background: avatarColor(name) } }, initialsFor(name).slice(0, 2)), name))); + if (!mention) { choose(0); return; } + mentions.replaceChildren(...mention.names.map((name, i) => el('button.wcc-mention', { type: 'button', role: 'option', id: `${listId}-${i}`, tabIndex: -1, onmousedown: e => e.preventDefault(), onclick: () => pick(name) }, el('span.wcc-avatar', { 'aria-hidden': 'true', style: { background: avatarColor(name) } }, initialsFor(name).slice(0, 2)), name))); + choose(0); }; const pick = name => { const end = input.selectionStart, text = `@${name} `; input.setRangeText(text, mention.start, end, 'end'); - mention = null; mentions.hidden = true; + mention = null; mentions.hidden = true; choose(0); input.dispatchEvent(new Event('input')); input.focus(); }; input.addEventListener('input', () => { submit.disabled = !input.value.trim(); grow(); showMentions(); if (key) unsent.set(key, input.value); }); - input.addEventListener('blur', () => { mentions.hidden = true; }); + input.addEventListener('blur', () => { mentions.hidden = true; choose(0); }); input.addEventListener('keydown', e => { if (isComposing(e)) return; if (mention && !mentions.hidden) { - if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); chosen = (chosen + (e.key === 'ArrowDown' ? 1 : -1) + mention.names.length) % mention.names.length; [...mentions.children].forEach((b, i) => b.setAttribute('aria-selected', String(i === chosen))); return; } + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); choose((chosen + (e.key === 'ArrowDown' ? 1 : -1) + mention.names.length) % mention.names.length); return; } if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); pick(mention.names[chosen]); return; } - if (e.key === 'Escape') { e.preventDefault(); mentions.hidden = true; mention = null; return; } + if (e.key === 'Escape') { e.preventDefault(); mentions.hidden = true; mention = null; choose(0); return; } } if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); if (input.value.trim()) onSubmit(input.value); } else if (e.key === 'Escape') { e.preventDefault(); onCancel?.(); } diff --git a/src/documents/tests/comment-column.test.js b/src/documents/tests/comment-column.test.js index 771403882..534ece718 100644 --- a/src/documents/tests/comment-column.test.js +++ b/src/documents/tests/comment-column.test.js @@ -118,3 +118,26 @@ it('offers no new comment where the document\'s protection allows none', () => { review.commands.newComment(); expect(review.layer.querySelector('.ed-comment-draft')).not.toBeNull(); }); + +it('tells assistive technology which person the arrow keys choose to mention', () => { + editor = new CanvasEditor(host, { paragraphs: [paragraph('Some text to discuss.')], comments: [] }); + review = createReview({ editor: () => editor, pkg: () => ({}), host: () => host, error: e => { throw e; }, author: () => 'Ada', hooks: { people: () => ['Ada Lovelace', 'Alan Turing', 'Grace Hopper'] } }); + review.refresh(true); + editor.model.select(0, 4); + review.commands.newComment(); + const box = review.layer.querySelector('.ed-comment-draft textarea'), list = review.layer.querySelector('.wcc-mentions'); + box.focus(); + expect(box.getAttribute('aria-controls')).toBe(list.id); + box.value = 'Ask @A'; box.setSelectionRange(6, 6); box.dispatchEvent(new Event('input')); + expect(list.hidden).toBe(false); + const options = [...list.querySelectorAll('[role="option"]')]; + expect(options.map(o => o.textContent)).toEqual(['ALAda Lovelace', 'ATAlan Turing']); + expect(box.getAttribute('aria-activedescendant')).toBe(options[0].id); + box.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + expect(box.getAttribute('aria-activedescendant')).toBe(options[1].id); + expect(options[1].getAttribute('aria-selected')).toBe('true'); + expect(options.every(o => o.tabIndex === -1)).toBe(true); + box.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(list.hidden).toBe(true); + expect(box.hasAttribute('aria-activedescendant')).toBe(false); +}); From 26fa953a0f4fcb01aaaf82b9c5ad83340a4309d5 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:45:58 -0700 Subject: [PATCH 014/186] Documents: Accept or Reject at the caret leaves the paragraph's formatting change alone With the caret in words typed into a paragraph that was also reformatted (both with Track Changes on), Accept or Reject resolved the words and the paragraph's formatting change around them: rejecting the words also undid the centering. They now act on the change the caret is in; the formatting change keeps its own balloon and entry. --- src/documents/src/views/review.js | 6 ++++++ src/documents/tests/review-navigation.test.js | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/documents/src/views/review.js b/src/documents/src/views/review.js index 3caade296..8fcc84b02 100644 --- a/src/documents/src/views/review.js +++ b/src/documents/src/views/review.js @@ -278,6 +278,12 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = // A change reached with Previous/Next (or the Reviewing Pane) and still selected as it was is the one meant. if (!change && scope === 'here' && navigated && a === navigated.start && b === Math.min(navigated.end, body.text.length) && body.revisions.some(c => c.start === navigated.start && c.end === navigated.end && c.type === navigated.type && c.author === navigated.author && c.date === navigated.date)) change = navigated; + // A caret in a change inside a reformatted paragraph means that change, not also the paragraph's formatting + // change around it (whose balloon and Reviewing Pane entry resolve it on its own). + if (!change && scope === 'here' && a === b) { + const around = body.revisionsAt(a, b), inner = around.filter(r => !around.some(o => o !== r && r.start <= o.start && o.end <= r.end && (r.start < o.start || o.end < r.end))); + if (inner.length === 1 && around.length > 1) change = inner[0]; + } const own = change && (c => c.type === change.type && c.author === change.author && c.date === change.date && !!c.move === !!change.move); // "All shown" leaves the changes of reviewers and kinds hidden by Show Markup alone. const ok = run(() => scope === 'shown' ? body.resolveRevisions(accept, undefined, undefined, { only: change => shownChanges([change]).length > 0 }) diff --git a/src/documents/tests/review-navigation.test.js b/src/documents/tests/review-navigation.test.js index 9874ea06a..bb3ec547e 100644 --- a/src/documents/tests/review-navigation.test.js +++ b/src/documents/tests/review-navigation.test.js @@ -82,3 +82,19 @@ it('leaves Track Changes as the document has it while protection allows only com review.commands.toggleTracking(); expect(review.commands.tracking).toBe(true); }); + +it('Accept and Reject with the caret in a change inside a reformatted paragraph act on that change alone', () => { + for (const accept of [true, false]) { + review?.destroy(); editor?.destroy(); + open(['First line.', 'Second line here.', 'Third line.']); + review.commands.toggleTracking(true); + editor.execute(() => { editor.model.select(19, 19); editor.model.replace('new '); }); + editor.execute(() => { editor.model.select(14, 14); editor.model.setParagraphFormat({ alignment: 'center' }); }); + editor.model.select(21, 21); + review.commands.resolve(accept); + expect(editor.model.text).toBe(accept ? 'First line.\nSecond new line here.\nThird line.' : 'First line.\nSecond line here.\nThird line.'); + // The paragraph's formatting change is still there to review. + expect(editor.model.revisions.map(r => r.type)).toEqual(['format']); + expect(editor.model.paragraphs[1].format.alignment).toBe('center'); + } +}); From 1eb717d0d8ee01a87f5b49ee9ee9c58eaa6bc1de Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:57:09 -0700 Subject: [PATCH 015/186] Documents: No Markup and Original hide the comments too No Markup and Original are meant to show the document without markup, but the comment cards and their tinted text stayed. They now hide the comments, as they hide changes and change bars, and Show Comments is unavailable there; going to a comment or starting one switches back to All Markup. --- src/documents/src/views/review.js | 33 +++++++++++++--------- src/documents/tests/comment-column.test.js | 27 ++++++++++++++++++ 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/documents/src/views/review.js b/src/documents/src/views/review.js index 8fcc84b02..8c1e9b12f 100644 --- a/src/documents/src/views/review.js +++ b/src/documents/src/views/review.js @@ -136,6 +136,11 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = // Protection other than locked tracking keeps Track Changes as the document has it. const trackingFixed = () => ['readOnly', 'comments', 'forms'].includes(hooks.protection?.()?.edit); const fixedTracking = () => error(new Error('This document is protected: Track Changes cannot be turned on or off.')); + // No Markup and Original show the document without any markup: its comments are hidden too. + const markupShown = () => !['none', 'original'].includes(revisionView.mode); + const showing = () => commentsShown && markupShown(); + // Going to a comment, or starting one, shows the comments (and the markup, in No Markup or Original). + const revealComments = () => { commentsShown = true; if (!markupShown()) { revisionView.mode = 'all'; editor()?.relayout(); } }; // Whether comments can be replied to, edited, resolved and deleted. const annotating = () => !!ready() && !commentsRefused(); const ready = () => { const engine = editor(); return engine && !pkg()?.readOnly && !engine.viewOnly && engine.model.comments ? engine : null; }; @@ -193,7 +198,7 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = if (commentsRefused()) { error(new Error('This document is protected: comments cannot be added.')); return; } engine.finishComposition(); const [start, end] = engine.model.commentRange(); - commentsShown = true; + revealComments(); draft = { start, end }; activeId = 'draft'; pinned = true; pinnedRange = editor()?.model.range.join() ?? ''; editing = null; refresh(true); queueMicrotask(() => layer.querySelector('.ed-comment-draft textarea')?.focus()); @@ -217,11 +222,11 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = let next; if (current >= 0) next = list[(current + direction + list.length) % list.length]; else next = direction > 0 ? list.find(t => t.start > a) || list[0] : [...list].reverse().find(t => t.start < b) || list.at(-1); - commentsShown = true; + revealComments(); selectThread(next); }, - toggleShown() { commentsShown = !commentsShown; refresh(true); onChange(); editor()?.focus(); }, - selectThreadById(id) { const thread = threads().find(t => t.id === id); if (thread) { commentsShown = true; selectThread(thread); } }, + toggleShown() { if (showing()) commentsShown = false; else revealComments(); refresh(true); onChange(); editor()?.focus(); }, + selectThreadById(id) { const thread = threads().find(t => t.id === id); if (thread) { revealComments(); selectThread(thread); } }, /** Delete every comment by the reviewers shown (Show Markup › Specific People). */ deleteAllShown() { const hidden = new Set(revisionView.options.hiddenAuthors); @@ -312,11 +317,11 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = // Painted once, after the view has moved to the change. engine.focus({ paint: false }); engine.revealCaret?.(); engine.paint(); onChange(); }, - get shown() { return commentsShown; }, + get shown() { return showing(); }, hasComments() { return threads().length > 0; }, /* ---- Panes and speech, for the title bar, menus and command search ---- */ get paneShown() { return paneShown; }, - togglePane(on = !paneShown) { paneShown = !!on; paneSignature = ''; if (paneShown && !commentsShown) commentsShown = true; refresh(true); if (!paneShown) editor()?.focus(); }, + togglePane(on = !paneShown) { paneShown = !!on; paneSignature = ''; if (paneShown && !showing()) revealComments(); refresh(true); if (!paneShown) editor()?.focus(); }, toggleReadAloud() { if (readAloud.active) readAloud.stop(); else readAloud.start(); }, readAloudActive: () => readAloud.active, }; @@ -528,7 +533,7 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = for (const t of list) if (t.start !== null && t.end > t.start && (!t.root.done || t.id === activeId)) ranges.push({ start: t.start, end: t.end, fill: t.id === activeId ? ACTIVE_TINT : TINT }); if (draft && draft.end > draft.start) ranges.push({ start: draft.start, end: draft.end, fill: ACTIVE_TINT }); ranges.sort((a, b) => a.start - b.start); - engine.setRangeTints?.(commentsShown ? ranges : [], 'comments'); + engine.setRangeTints?.(showing() ? ranges : [], 'comments'); // Editable regions of a protected document are their own layer. engine.setRangeTints?.(hooks.tints?.() || [], 'permissions'); } @@ -552,12 +557,12 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = } followDraft(engine); const balloons = balloonChanges(); - const visible = commentsShown && (list.length > 0 || !!draft) || balloons.length > 0; + const visible = showing() && (list.length > 0 || !!draft) || balloons.length > 0; if (scroller.classList.contains('has-comment-markup') !== visible) { scroller.classList.toggle('has-comment-markup', visible); positionsKey = ''; queueMicrotask(onShift); } layer.hidden = !visible; // Where the threads are anchored does not change the cards (place() follows the anchors), so typing before // a comment does not rebuild every card. - const sig = JSON.stringify([JSON.stringify(list, (key, value) => key === 'start' || key === 'end' ? undefined : value), activeId, editing, !!draft, commentsShown, !!ready(), annotating(), balloons, locked()]); + const sig = JSON.stringify([JSON.stringify(list, (key, value) => key === 'start' || key === 'end' ? undefined : value), activeId, editing, !!draft, showing(), !!ready(), annotating(), balloons, locked()]); if (force || sig !== signature) { signature = sig; const focused = layer.contains(document.activeElement) ? document.activeElement : null; @@ -567,8 +572,8 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = // doing that for each comment made rebuilding the column of a much-commented document slow. let people = null; const names = () => people ??= hooks.people?.() || []; - const nodes = visible && commentsShown ? list.map(thread => { const node = card(thread, names); cards.set(thread.id, node); return node; }) : []; - if (visible && commentsShown && draft) nodes.push(draftCard()); + const nodes = visible && showing() ? list.map(thread => { const node = card(thread, names); cards.set(thread.id, node); return node; }) : []; + if (visible && showing() && draft) nodes.push(draftCard()); if (balloons.length) { const body = engine.session.body, documentText = body.text, summarize = formatSummaries(body); nodes.push(...balloons.map(change => balloonCard(change, documentText, summarize))); } layer.replaceChildren(...nodes); // A card that grows (a reply or edit being typed, a wrapped draft) pushes the cards below it down. @@ -614,7 +619,7 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = const resolve = command('Resolve Comment', 'resolveComment', () => commands.resolveActive(), { text: 'Resolve' }); const show = command('Show Comments', 'showComments', () => commands.toggleShown(), { size: 'large', text: 'Show Comments' }); const showSplit = split(show, { label: 'Show Comments options', size: 'large', open: (anchor, e) => openMenu({ anchor, label: 'Show Comments', pointer: !!e?.detail, onClose: e?.detail ? () => editor()?.focus() : null, items: [ - { label: 'Contextual', radio: true, checked: commentsShown, onSelect: () => { if (!commentsShown) commands.toggleShown(); } }, + { label: 'Contextual', radio: true, checked: showing(), onSelect: () => { if (!showing()) commands.toggleShown(); } }, { label: 'List', radio: true, checked: false, onSelect: () => hooks.commentsPane?.() }, ] }) }); for (const b of [add, remove, previous, next, show, resolve]) b.addEventListener('mousedown', e => e.preventDefault()); @@ -623,8 +628,8 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = when(removeSplit.arrow, () => annotating() && threads().length > 0); when(previous, () => threads().length > 0); when(next, () => threads().length > 0); when(resolve, () => annotating() && !!(commands.activeThread() || threadAtCaret())); - when(show, () => !!editor()?.model.comments, () => String(commentsShown)); - when(showSplit.arrow, () => !!editor()?.model.comments); + when(show, () => !!editor()?.model.comments && markupShown(), () => String(showing())); + when(showSplit.arrow, () => !!editor()?.model.comments && markupShown()); const track = command('Track Changes', 'trackChanges', () => commands.toggleTracking(), { size: 'large', text: 'Track Changes', shortcut: `${MOD_KEY}+Shift+E` }); const trackSplit = split(track, { label: 'Track Changes options', size: 'large', open: (anchor, e) => openMenu({ anchor, label: 'Track Changes', pointer: !!e?.detail, onClose: e?.detail ? () => editor()?.focus() : null, items: [ { label: 'For Everyone', radio: true, checked: commands.trackingMode === 'everyone', disabled: !commands.canTrack(), hint: 'Saved with the document', onSelect: () => commands.setTrackingMode('everyone') }, diff --git a/src/documents/tests/comment-column.test.js b/src/documents/tests/comment-column.test.js index 534ece718..96eb3134f 100644 --- a/src/documents/tests/comment-column.test.js +++ b/src/documents/tests/comment-column.test.js @@ -3,6 +3,7 @@ import { beforeEach, afterEach, it, expect, vi } from 'vitest'; import { CanvasEditor } from '../src/engine/canvas-editor.js'; import { paragraph } from '../src/engine/model.js'; import { createReview } from '../src/views/review.js'; +import { revisionView } from '../src/engine/revisions.js'; let editor, host, review; beforeEach(() => { @@ -141,3 +142,29 @@ it('tells assistive technology which person the arrow keys choose to mention', ( expect(list.hidden).toBe(true); expect(box.hasAttribute('aria-activedescendant')).toBe(false); }); + +it('hides the comments in No Markup and Original, and going to a comment shows the markup again', () => { + open(['First paragraph here.', 'Second paragraph here.']); + const first = editor.model.addComment({ author: 'Ada', text: 'One', range: [0, 5] }); + review.refresh(true); + expect(review.layer.hidden).toBe(false); + try { + for (const mode of ['none', 'original']) { + review.commands.setMarkupView(mode); review.refresh(); + expect(review.layer.hidden, mode).toBe(true); + expect(review.layer.querySelector('[data-thread]')).toBeNull(); + expect(review.commands.shown).toBe(false); + review.commands.setMarkupView('simple'); review.refresh(); + expect(card(first)).not.toBeNull(); + } + review.commands.setMarkupView('none'); review.refresh(); + review.commands.go(1); review.refresh(); + expect(revisionView.mode).toBe('all'); + expect(card(first).classList.contains('is-active')).toBe(true); + review.commands.setMarkupView('original'); review.refresh(); + editor.model.select(30, 34); + review.commands.newComment(); + expect(revisionView.mode).toBe('all'); + expect(review.layer.querySelector('.ed-comment-draft')).not.toBeNull(); + } finally { revisionView.mode = 'all'; } +}); From f999bf857513c2d5f40f964a24156286f6416ad6 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:03:48 -0700 Subject: [PATCH 016/186] Documents: The Reviewing Pane leaves out the changes and comments Show Markup hides With a reviewer, or insertions and deletions, hidden in Show Markup, the Reviewing Pane still listed and counted them, though the page and the margin showed none of them. It now lists what is shown. --- src/documents/src/views/review.js | 3 ++- src/documents/tests/review-navigation.test.js | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/documents/src/views/review.js b/src/documents/src/views/review.js index 8c1e9b12f..4f5a04b4d 100644 --- a/src/documents/src/views/review.js +++ b/src/documents/src/views/review.js @@ -59,7 +59,8 @@ export function createReview({ editor, pkg, host, error, author, onChange = () = paneSummary, paneList); /** The Reviewing Pane's entries, every comment and change in document order, as the document is now. */ function paneItems(engine) { - const body = engine.session.body, changes = body.revisions, notes = threads(); + // What Show Markup hides (a reviewer, a kind of change) is left out, as it is from the page and the margin. + const body = engine.session.body, changes = shownChanges(body.revisions), notes = threads().filter(t => !revisionView.options.hiddenAuthors.includes(t.root.author)); // The document's text, read once: each read checks every paragraph, and reading it per change made typing // with the pane open slow in a document with many changes. const text = body.text, formatSummary = formatSummaries(body); diff --git a/src/documents/tests/review-navigation.test.js b/src/documents/tests/review-navigation.test.js index bb3ec547e..9767d6de9 100644 --- a/src/documents/tests/review-navigation.test.js +++ b/src/documents/tests/review-navigation.test.js @@ -98,3 +98,24 @@ it('Accept and Reject with the caret in a change inside a reformatted paragraph expect(editor.model.paragraphs[1].format.alignment).toBe('center'); } }); + +it('the Reviewing Pane lists only what Show Markup shows', () => { + open(['First line.', 'Second line.']); + editor.model.trackChanges = true; + for (const [author, at, text] of [['Ada', 0, 'A '], ['Grace', 14, 'G ']]) { + editor.model.revisionAuthor = author; + editor.execute(() => { editor.model.select(at); editor.model.replace(text); }); + } + editor.model.trackChanges = false; + editor.model.addComment({ author: 'Grace', text: 'Hers', range: [16, 20] }); + revisionView.options.hiddenAuthors = ['Grace']; + try { + review.commands.toggleReviewingPane(true); + const entries = () => [...review.pane.querySelectorAll('.review-pane-item')].map(item => item.textContent); + expect(entries()).toEqual(['Ada InsertedA']); + expect(review.pane.querySelector('.review-pane-summary').textContent).toBe('1 revision: 1 insertion, 0 deletions, 0 formatting, 0 comments'); + revisionView.options.hiddenAuthors = []; + review.refresh(); + expect(entries()).toEqual(['Ada InsertedA', 'Grace InsertedG', 'Grace CommentHers']); + } finally { revisionView.options.hiddenAuthors = []; } +}); From d998313f2d0f573328e436040d2a87be00d716f6 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:30:43 -0700 Subject: [PATCH 017/186] Documents: ask whether to save changes when leaving with AutoSave off Leaving the document (All Documents, an import, the Puter window's close button) flushed pending edits with an explicit save, so with AutoSave off an experiment the user meant to throw away replaced the file without a word. Leaving now asks: Save, Don't Save (the file stays as it was), or Cancel. --- src/documents/src/main.js | 17 ++++- src/documents/src/views/window-close.js | 30 ++++++++ .../tests/browser/autosave-off-leave.spec.js | 68 +++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 src/documents/tests/browser/autosave-off-leave.spec.js diff --git a/src/documents/src/main.js b/src/documents/src/main.js index fb2be2d24..f039b2d65 100644 --- a/src/documents/src/main.js +++ b/src/documents/src/main.js @@ -16,7 +16,7 @@ import { importFiles, nothingImportable } from './lib/importfiles.js'; import { imageFilesFrom } from './lib/images.js'; import { customTemplatePath } from './lib/customtemplates.js'; import { parseLink } from './collab/protocol.js'; -import { CLOSE_TITLE, createCloseWindow } from './views/window-close.js'; +import { CLOSE_TITLE, SAVE_CHANGES_TITLE, asksToSave, createCloseWindow, saveChangesDialog } from './views/window-close.js'; const appRoot = document.getElementById('app'); let current = null; // { kind: 'home' | 'editor' | 'gate', view } @@ -68,7 +68,20 @@ async function unmount(stillWanted = () => true) { if (leaving?.kind === 'editor') { const doc = leaving.view.doc; const ownHash = () => docHash(doc); + // With AutoSave off, leaving asks whether to save: Don't Save leaves the file as it was. + leaving.view.engine?.finishComposition?.(); + let discard = false; + if (asksToSave(doc)) { + history.replaceState(null, '', ownHash()); + const choice = await saveChangesDialog(doc); + if (!stillWanted() || current !== leaving) return false; + const moved = location.hash !== ownHash(); + if (choice === 'stay') { if (moved) history.replaceState(null, '', ownHash()); return false; } + if (!moved) history.replaceState(null, '', destination); + discard = choice === 'discard'; + } for (;;) { + if (discard) break; const saved = await leaving.view.flush(); if (!stillWanted() || current !== leaving) return false; if (saved) break; @@ -94,7 +107,7 @@ async function unmount(stillWanted = () => true) { return true; } -const LEAVE_TITLES = ['Leave without saving?', 'Save before leaving?', CLOSE_TITLE]; +const LEAVE_TITLES = ['Leave without saving?', 'Save before leaving?', CLOSE_TITLE, SAVE_CHANGES_TITLE]; /** Whether the question about leaving an unsaved document is on screen, whichever navigation or import asked it. */ const leaveQuestionOpen = () => [...document.querySelectorAll('.modal-root:not(.closing) [role="dialog"]')].some((dialog) => LEAVE_TITLES.includes(dialog.getAttribute('aria-label'))); diff --git a/src/documents/src/views/window-close.js b/src/documents/src/views/window-close.js index 9771cbb93..17f71d3d0 100644 --- a/src/documents/src/views/window-close.js +++ b/src/documents/src/views/window-close.js @@ -13,6 +13,30 @@ import * as P from '../lib/puter.js'; */ export const CLOSE_TITLE = 'Save before closing?'; +export const SAVE_CHANGES_TITLE = 'Save changes?'; + +/** + * Whether leaving the document has to ask about its changes first: with AutoSave off only Save writes the file, + * so leaving must not write the changes on its own (the user may mean to throw them away), nor drop them unasked. + * A document waiting for a first Save to ask where it goes, one someone else saves, and one without a session (the + * sign-in question covers it) are asked about elsewhere. + */ +export function asksToSave(doc) { + return !!doc && !doc.autosave && doc.dirty && !!doc.exporter && doc.writes !== false && !doc._deleted && !doc.waitsForPlace && !session.signedOut; +} + +/** The question about changes AutoSave is not writing: 'save', 'discard', or 'stay'. */ +export function saveChangesDialog(doc) { + return new Promise((resolve) => { + const content = el('div', {}, + el('p.modal-text', {}, `AutoSave is off, so your changes to “${doc.name}” have not been saved. Save them before leaving?`), + el('div.row.row-end', { style: { flexWrap: 'wrap' } }, + el('button.btn.btn-ghost', { type: 'button', onclick: () => modal.close() }, 'Cancel'), + el('button.btn.btn-danger', { type: 'button', onclick: () => { resolve('discard'); modal.close(); } }, 'Don’t Save'), + el('button.btn.btn-primary', { type: 'button', onclick: () => { resolve('save'); modal.close(); } }, 'Save'))); + const modal = openModal({ title: SAVE_CHANGES_TITLE, content, onClose: () => resolve('stay') }); + }); +} /** The question about a never-saved document: 'save', 'close' (and lose it), or 'stay'. */ export function closeDialog(doc) { @@ -40,6 +64,12 @@ export async function readyToClose({ open, leaveDialog }) { if (shown?.kind !== 'editor') return true; const { view } = shown, doc = view.doc; const still = () => open() === shown; + view.engine?.finishComposition?.(); + if (asksToSave(doc)) { + const choice = await saveChangesDialog(doc); + if (!still() || choice === 'stay') return false; + if (choice === 'discard') return true; + } if (doc.waitsForPlace) { const choice = await closeDialog(doc); if (!still() || choice === 'stay') return false; diff --git a/src/documents/tests/browser/autosave-off-leave.spec.js b/src/documents/tests/browser/autosave-off-leave.spec.js new file mode 100644 index 000000000..51748e6ee --- /dev/null +++ b/src/documents/tests/browser/autosave-off-leave.spec.js @@ -0,0 +1,68 @@ +import { test, expect } from '@playwright/test'; +import { unzipSync, strFromU8 } from 'fflate'; + +/** + * With AutoSave off only Save writes the file. Leaving the document (All Documents, or the Puter window's close + * button) with changes Save has not written asks whether to save them, as closing does in a desktop word + * processor: leaving used to write them without asking, so an experiment the user meant to throw away + * replaced the file. + */ + +const text = page => page.getByRole('textbox', { name: 'Document text' }); +const status = page => page.locator('.canvas-save-status'); +async function fileText(page, path) { + const bytes = await page.evaluate(async p => [...new Uint8Array(await (await puter.fs.read(p)).arrayBuffer())], path); + const xml = strFromU8(unzipSync(Uint8Array.from(bytes))['word/document.xml']); + return [...xml.matchAll(/]*)?>([^<]*)<\/w:t>/g)].map(m => m[1]).join(''); +} + +async function savedWithAutoSaveOff(page, query = '') { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto(`/mock.html${query}#/new`); + await expect(page.getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + await text(page).fill('Kept'); + await text(page).press('ControlOrMeta+s'); + await expect(status(page)).toHaveAttribute('aria-label', 'Saved to Puter'); + await page.locator('label.ed-autosave').click(); + await expect(page.getByRole('switch', { name: 'AutoSave' })).not.toBeChecked(); + await text(page).press('End'); + await page.keyboard.type(' experiment'); + await expect(status(page)).toHaveAttribute('aria-label', 'Unsaved changes'); + return page.evaluate(() => window.__docs.current.view.doc.path); +} + +test.afterEach(async ({ page }) => { await page.evaluate(() => localStorage.removeItem('documents:autosave')).catch(() => {}); }); + +test('leaving with AutoSave off asks before saving; Don’t Save keeps the file as it was', async ({ page }) => { + const path = await savedWithAutoSaveOff(page); + await page.getByRole('button', { name: 'All Documents' }).click(); + const question = page.getByRole('dialog', { name: 'Save changes?' }); + await expect(question).toBeVisible(); + // Cancel stays in the document with its changes. + await question.getByRole('button', { name: 'Cancel' }).click(); + await expect(question).toHaveCount(0); + await expect(text(page)).toHaveValue('Kept experiment'); + expect(await fileText(page, path)).toBe('Kept'); + await page.getByRole('button', { name: 'All Documents' }).click(); + await question.getByRole('button', { name: 'Don’t Save' }).click(); + await expect(page.locator('.file-row .file-name')).toHaveText(['Untitled document']); + expect(await fileText(page, path)).toBe('Kept'); +}); + +test('leaving with AutoSave off and choosing Save writes the changes', async ({ page }) => { + const path = await savedWithAutoSaveOff(page); + await page.getByRole('button', { name: 'All Documents' }).click(); + await page.getByRole('dialog', { name: 'Save changes?' }).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(page.locator('.file-row .file-name')).toHaveText(['Untitled document']); + expect(await fileText(page, path)).toBe('Kept experiment'); +}); + +test('the Puter window’s close button asks too with AutoSave off', async ({ page }) => { + const path = await savedWithAutoSaveOff(page, '?env=app'); + const closing = page.evaluate(() => window.__closeWindow()); + const question = page.getByRole('dialog', { name: 'Save changes?' }); + await question.getByRole('button', { name: 'Don’t Save' }).click(); + expect(await closing).toBe(true); + expect(await page.evaluate(() => window.__exits || 0)).toBe(1); + expect(await fileText(page, path)).toBe('Kept'); +}); From d0530224e21f2ad7f020e01d25c806abb6fb5b29 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:32:04 -0700 Subject: [PATCH 018/186] Documents: autosave typing that never pauses within half a minute Autosave waited for a pause after the last edit (1.5 s, up to 30 s after slow saves), and every keystroke restarted the wait. Someone typing or dictating steadily for minutes got no save at all, and a crash or closed tab lost all of it. Edits now save at the latest half a minute after the first unsaved one. --- src/documents/src/lib/doc.js | 10 +++++++++- src/documents/tests/doc.test.js | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/documents/src/lib/doc.js b/src/documents/src/lib/doc.js index 8d07e922f..3d6a3fe77 100644 --- a/src/documents/src/lib/doc.js +++ b/src/documents/src/lib/doc.js @@ -155,6 +155,7 @@ export class Doc extends EventTarget { /** Nothing on screen is worth saving (a file the engine could not open, a reopen): no edits pending, and the status says so. */ discardEdits() { this.savedVersion = this.version; + this._pendingSince = null; this._setStatus('saved'); } @@ -206,7 +207,12 @@ export class Doc extends EventTarget { this.version += 1; if (this.writes === false) { this.savedVersion = this.version; return; } this._setStatus('unsaved'); - if (this.exporter && this.autosave) this._schedule(this.autosaveDelay); + if (this.exporter && this.autosave) { + // A pause in the typing saves. Typing that never pauses that long (a long passage typed or dictated without + // stopping) still saves within MAX_AUTOSAVE_MS of its first unsaved edit, or a crash would take all of it. + this._pendingSince ??= Date.now(); + this._schedule(Math.max(0, Math.min(this.autosaveDelay, this._pendingSince + MAX_AUTOSAVE_MS - Date.now()))); + } } /** AutoSave (on unless turned off in the title bar): edits save themselves; off, they wait for Save. */ @@ -300,6 +306,8 @@ export class Doc extends EventTarget { // The pace follows the export and upload alone: the wait for an import's layout is not save time. const started = Date.now(); const version = this.version; + // Edits from here on are not in this save: the longest wait for them starts with the first of them. + this._pendingSince = null; let target = this.path; if (!this.path || !this.isDocxPath) { // First save, or a non-docx source: pick a fresh name in the folder, the document's own (a rename before this save counts). diff --git a/src/documents/tests/doc.test.js b/src/documents/tests/doc.test.js index 1a8d78496..2f35d3ec9 100644 --- a/src/documents/tests/doc.test.js +++ b/src/documents/tests/doc.test.js @@ -193,6 +193,28 @@ describe('Doc', () => { expect(P.writeFile).toHaveBeenCalledTimes(1); }); + it('saves typing that never pauses within half a minute of its first unsaved edit', async () => { + const doc = Doc.blank(); + doc.attach(async () => blob(`version ${doc.version}`)); + // A keystroke every second: no pause ever reaches the autosave's wait. + for (let i = 0; i < 29; i++) { doc.changed(); await vi.advanceTimersByTimeAsync(1000); } + expect(P.writeFile).not.toHaveBeenCalled(); + doc.changed(); + await vi.advanceTimersByTimeAsync(1000); + expect(P.writeFile).toHaveBeenCalledTimes(1); + // The edits typed meanwhile wait no longer than that either. + for (let i = 0; i < 30; i++) { doc.changed(); await vi.advanceTimersByTimeAsync(1000); } + expect(P.writeFile).toHaveBeenCalledTimes(2); + // A pause still saves after the usual wait. + doc.changed(); + await vi.advanceTimersByTimeAsync(AUTOSAVE_MS - 5); + expect(P.writeFile).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(10); + expect(P.writeFile).toHaveBeenCalledTimes(3); + expect(doc.dirty).toBe(false); + doc.destroy(); + }); + it('keeps the document dirty when an edit lands mid-save, then saves again', async () => { const doc = Doc.blank(); let resolveExport; From 8bd0bd88f5ad6666fbd9e8582ffbedb15c91be90 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:33:42 -0700 Subject: [PATCH 019/186] Documents: keep list numbers and bullets in a PDF's searchable text The PDF text layer took only each line's text fragments; a list item's number or bullet is painted apart from them, so copying or searching an exported PDF gave "First step" for "1. First step" and numbered headings lost their numbers. --- src/documents/src/lib/pdf-export.js | 3 +++ src/documents/tests/pdf-export.test.js | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/documents/src/lib/pdf-export.js b/src/documents/src/lib/pdf-export.js index f1e378db0..b13885c83 100644 --- a/src/documents/src/lib/pdf-export.js +++ b/src/documents/src/lib/pdf-export.js @@ -137,6 +137,9 @@ export function pageFragments(lines, page) { const out = []; for (const line of lines) { if (line.page !== page || line.virtual || line.cellText) continue; + // A list item's number or bullet is painted apart from its text, and is read with it: "1. First step". + const marker = line.marker; + if (marker?.text && marker.width > 0) out.push({ text: marker.text, x: marker.x, baseline: line.baseline, width: marker.width, fontSize: marker.fontSize }); for (const f of line.fragments || []) if (f.text && !f.image) out.push({ text: f.text, x: f.x, baseline: line.baseline, width: f.width, fontSize: f.fontSize }); } return out; diff --git a/src/documents/tests/pdf-export.test.js b/src/documents/tests/pdf-export.test.js index 43d3d992f..bc53894a0 100644 --- a/src/documents/tests/pdf-export.test.js +++ b/src/documents/tests/pdf-export.test.js @@ -56,3 +56,23 @@ it('gives emoji and characters beyond the BMP back when text is copied, and keep expect(execFileSync('pdftotext', [file, '-'], { encoding: 'utf8' })).toContain('Smile 😀 and 𠀋 here'); expect(execFileSync('pdfinfo', [file], { encoding: 'utf8' })).toContain('Café Q3 – 報告'); }); + +it('puts list numbers and bullets in the searchable text, before their item\'s text', async () => { + const { TextDocument, paragraph } = await import('../src/engine/model.js'); + const { layoutDocument } = await import('../src/engine/layout.js'); + const measure = text => ({ width: [...text].length * 8, ascent: 12, descent: 3 }); + const doc = new TextDocument([paragraph()]); + doc.replace('First step'); doc.toggleList('number'); + doc.insertParagraph(); doc.replace('Second step'); + doc.insertParagraph(); doc.replace('Point'); doc.toggleList('bullet'); + const { lines } = layoutDocument(doc.paragraphs, measure, undefined, doc.numbering, doc.tables); + const fragments = pageFragments(lines, 0); + expect(fragments.map(f => f.text)).toEqual(['1.', 'First step', '2.', 'Second step', '•', 'Point']); + // The number sits where it is painted, left of the text, on the same baseline. + const [number, text] = fragments; + expect(number.x).toBe(lines[0].marker.x); + expect(number.x).toBeLessThan(text.x); + expect(number.baseline).toBe(text.baseline); + expect(number.width).toBeGreaterThan(0); + expect(number.fontSize).toBeGreaterThan(0); +}); From a61f7199a2c17daaef3cc41cda460cc80a5e19fb Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:35:13 -0700 Subject: [PATCH 020/186] Documents: keep equations and symbols in web page, Markdown and text exports Exports removed every object character, so an equation ("E=mc2") and a symbol from Insert Symbol vanished from the exported text, though copying the same text keeps them. They now export as the text copying gives; a symbol in a symbol font's private range, which reads as an empty box without that font, and pictures still go. --- src/documents/src/lib/document-export.js | 15 +++++++++++++-- src/documents/tests/document-export.test.js | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/documents/src/lib/document-export.js b/src/documents/src/lib/document-export.js index f9bda1753..f94c58041 100644 --- a/src/documents/src/lib/document-export.js +++ b/src/documents/src/lib/document-export.js @@ -8,11 +8,22 @@ import { documentBlocks } from '../engine/tables.js'; import { listKind, listMarkers, levelFor } from '../engine/numbering.js'; import { TextDocument } from '../engine/model.js'; import { hasRevisions } from '../engine/revisions.js'; +import { bodySymbolText } from '../engine/body-symbols.js'; const OBJECT = ''; const escapeHtml = text => String(text).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); -// Hidden text is neither shown nor printed, so a downloaded or published copy leaves it out too. -const runText = run => run.vanish ? '' : String(run.text || '').replace(new RegExp(OBJECT, 'g'), ''); +// Hidden text is neither shown nor printed, so a downloaded or published copy leaves it out too. An object stands +// for text of its own, as copying it gives: an equation its text, a field its result, a symbol its character (one +// in a symbol font's private range would read as an empty box without that font, so it goes). Pictures go. +const PRIVATE_USE = /[\ue000-\uf8ff]/; +function objectText(run) { + if (run.image) return ''; + if (run.bodySymbol) { const symbol = bodySymbolText(run, ''); return PRIVATE_USE.test(symbol) ? '' : symbol; } + if (run.math) return run.math.text || ''; + if (run.field) return run.field.cachedText || ''; + return ''; +} +const runText = run => run.vanish ? '' : run.text === OBJECT ? objectText(run).replace(/[\u0000-\u0008\u000b-\u001f]/g, '') : String(run.text || '').replace(new RegExp(OBJECT, 'g'), ''); /** A heading level 1–6 from a paragraph's style, or 0. */ export function headingLevel(p, styles) { diff --git a/src/documents/tests/document-export.test.js b/src/documents/tests/document-export.test.js index e0dc8908a..703b3516f 100644 --- a/src/documents/tests/document-export.test.js +++ b/src/documents/tests/document-export.test.js @@ -351,3 +351,23 @@ describe('web page lists numbered with letters or Roman numerals', () => { } }); }); + +describe('equations and symbols', () => { + const MATH = 'E=mc2'; + const doc = () => { + const d = blankDoc(); + d.replace('Energy: '); + d.insertMathRun({ xml: MATH, text: 'E=mc2' }); + d.replace(' then '); + // Symbols inserted with Word's Insert Symbol (w:sym): one a Unicode arrow, one in a symbol font's private range. + const at = d.paragraphs[0].runs.at(-1); + d.paragraphs[0].runs.push({ ...at, text: '', bodySymbol: { font: 'Arial', code: '2192' } }, { ...at, text: ' next', bodySymbol: undefined }, + { ...at, text: '', bodySymbol: { font: 'Wingdings', code: 'F0FC' } }, { ...at, text: ' done', bodySymbol: undefined }); + return d; + }; + it('keep their text in every format, as copying them does', () => { + expect(documentPlainText(doc())).toBe('Energy: E=mc2 then → next done\n'); + expect(documentMarkdown(doc())).toBe('Energy: E=mc2 then → next done\n'); + expect(body(documentHtml(doc()))).toBe('

Energy: E=mc2 then → next done

'); + }); +}); From a9ec37f71b75afc5169851ca193a31ecbf8b903a Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:36:55 -0700 Subject: [PATCH 021/186] Documents: keep restarted list numbers in web page and Markdown exports A list numbered 1, 2, 1, 2 (Restart Numbering, or a number set part-way through) exported as one
    with no number on the restarted item, and as Markdown that every reader numbers 1, 2, 3, 4 (CommonMark takes a list's first number only). The web page now gives such an item its value, and Markdown starts a new list after a comment that ends the one before. --- src/documents/src/lib/document-export.js | 25 ++++++++++++---- src/documents/tests/document-export.test.js | 33 +++++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/documents/src/lib/document-export.js b/src/documents/src/lib/document-export.js index f94c58041..61092cc6d 100644 --- a/src/documents/src/lib/document-export.js +++ b/src/documents/src/lib/document-export.js @@ -124,24 +124,32 @@ function blocksHtml(blocks, model, numbers = itemNumbers(model), markers = itemM } const p = paragraphOf(block), level = headingLevel(p, model.styles); const kind = level ? null : kindOf(p, model.numbering), tag = kind === 'bullet' ? 'ul' : kind ? 'ol' : null; + let value = null; if (!tag) closeLists(); else { const depth = listLevel(p); while (lists.length && lists.at(-1).depth > depth) closeList(); if (lists.at(-1)?.depth === depth && lists.at(-1).tag !== tag) closeList(); - if (lists.at(-1)?.depth === depth) html += ''; - else { - const start = tag === 'ol' ? numbers.get(p) ?? 1 : 1; + const number = tag === 'ol' ? numbers.get(p) : null; + if (lists.at(-1)?.depth === depth) { + html += ''; + // An item numbered again part-way through (Restart Numbering, Set Numbering Value) says its number; a browser + // would number it one past the item before. + const list = lists.at(-1); + if (tag === 'ol' && number != null && number !== list.number + 1) value = number; + list.number = number ?? list.number + 1; + } else { + const start = number ?? 1; // A list numbered with letters or Roman numerals keeps them: "see (b)" still finds item b. const type = tag === 'ol' ? LIST_TYPES[(() => { try { return levelFor(model.numbering, p.list).format; } catch { return null; } })()] : null; html += `<${tag}${type ? ` type="${type}"` : ''}${start !== 1 ? ` start="${start}"` : ''}>`; - lists.push({ tag, depth }); + lists.push({ tag, depth, number: start }); } } const inner = escapeHtml(headingNumber(p, level, markers)) + p.runs.map(run => runHtml(run, !!level)).join('') || '
    '; const alignment = p.format?.alignment; const align = ['center', 'right', 'both'].includes(alignment) ? ` style="text-align:${alignment === 'both' ? 'justify' : alignment}"` : ''; - html += tag ? `${inner}` : level ? `${inner}` : `${inner}

    `; + html += tag ? `${inner}` : level ? `${inner}` : `${inner}

    `; } closeLists(); return html; @@ -305,12 +313,17 @@ function blocksMarkdown(blocks, model) { let list = lists.at(-1); if (list?.depth !== depth) { list = { depth, indent: list ? list.content : 0, kind: null, number: 0 }; lists.push(list); } const number = numbers.get(p) ?? (list.kind === kind ? list.number + 1 : 1); + // CommonMark takes a list's number from its first item alone: an item numbered again part-way through + // (Restart Numbering, Set Numbering Value) starts a list of its own, after a comment that ends the one before. + const restarted = previousList && kind !== 'bullet' && list.kind && list.kind !== 'bullet' && number !== list.number + 1; Object.assign(list, { kind, number }); const marker = kind === 'bullet' ? '-' : `${number}.`, indent = ' '.repeat(list.indent); list.content = list.indent + marker.length + 1; // A line after a break is indented to the item's text, so it stays in the item. const line = `${indent}${marker} ${text.replace(/\n/g, `\n${' '.repeat(list.content)}`)}`; - if (previousList) out[out.length - 1] += `\n${line}`; else out.push(line); + if (restarted && !list.indent) out.push('', line); + else if (restarted) out[out.length - 1] += `\n\n${indent}\n\n${line}`; + else if (previousList) out[out.length - 1] += `\n${line}`; else out.push(line); previousList = kind; continue; } diff --git a/src/documents/tests/document-export.test.js b/src/documents/tests/document-export.test.js index 703b3516f..1c8582810 100644 --- a/src/documents/tests/document-export.test.js +++ b/src/documents/tests/document-export.test.js @@ -371,3 +371,36 @@ describe('equations and symbols', () => { expect(body(documentHtml(doc()))).toBe('

    Energy: E=mc2 then → next done

    '); }); }); + +describe('a list numbered again part-way through', () => { + const doc = () => { + const d = blankDoc(); + d.replace('a'); d.toggleList('number'); + d.insertParagraph(); d.replace('b'); + d.insertParagraph(); d.replace('c'); d.restartNumbering(1); + d.insertParagraph(); d.replace('d'); + return d; + }; + it('keeps its numbers in a web page and in Markdown', () => { + expect(documentPlainText(doc())).toBe('1.\ta\n2.\tb\n1.\tc\n2.\td\n'); + // A browser numbers each item one past the one before unless told otherwise. + expect(body(documentHtml(doc()))).toBe('
    1. a
    2. b
    3. c
    4. d
    '); + // CommonMark takes only a list's first number: the restarted items start a list of their own. + const markdown = documentMarkdown(doc()); + expect(markdown).toBe('1. a\n2. b\n\n\n\n1. c\n2. d\n'); + const html = markdownHtml(markdown); + expect([...html.matchAll(//g)].map(m => m[1] ?? '1')).toEqual(['1', '1']); + }); + it('keeps a number set part-way through a nested list', () => { + const d = blankDoc(); + d.replace('a'); d.toggleList('number'); + d.insertParagraph(); d.replace('a1'); d.changeListLevel(1); + d.insertParagraph(); d.replace('a2'); + d.insertParagraph(); d.replace('a3'); d.restartNumbering(5); + d.insertParagraph(); d.replace('b'); d.changeListLevel(-1); + expect(documentPlainText(d)).toBe('1.\ta\n 1.1.\ta1\n 1.2.\ta2\n 1.5.\ta3\n2.\tb\n'); + expect(body(documentHtml(d))).toContain('
  1. a3
  2. '); + const markdown = documentMarkdown(d); + expect([...markdownHtml(markdown).matchAll(//g)].map(m => m[1] ?? '1')).toEqual(['1', '1', '5']); + }); +}); From e74520b0e1a034cd9a9e566af328d27204e91664 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:37:19 -0700 Subject: [PATCH 022/186] Documents: keep the numbers and bullets of lists in table cells in Markdown exports A Markdown table cell cannot hold a list, and its items were written without their markers, so "1. Step one / 2. Step two" became "Step one
    Step two". The items now keep their numbers and bullets as text, as the plain-text export does. --- src/documents/src/lib/document-export.js | 4 +++- src/documents/tests/document-export.test.js | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/documents/src/lib/document-export.js b/src/documents/src/lib/document-export.js index 61092cc6d..8f5f71f6d 100644 --- a/src/documents/src/lib/document-export.js +++ b/src/documents/src/lib/document-export.js @@ -295,7 +295,9 @@ function blocksMarkdown(blocks, model) { for (const block of blocks) { if (block.type === 'table') { // Text already escapes its pipes (and link targets encode theirs), so none ends a cell early. - const rows = tableRows(block, cell => cellText(cell, p => paragraphMarkdown(p, false, '
    '), ' \\| ', '
    ')).map(row => row.map(text => text || ' ')); + // A cell cannot hold a Markdown list: an item keeps its number or bullet as text, as in plain text. + const item = p => markers.has(p) ? mdEscape(markers.get(p).replace(/\t$/, ' ')) : ''; + const rows = tableRows(block, cell => cellText(cell, p => item(p) + paragraphMarkdown(p, false, '
    '), ' \\| ', '
    ')).map(row => row.map(text => text || ' ')); if (rows.length) { const width = Math.max(...rows.map(r => r.length)); const pad = r => [...r, ...Array(width - r.length).fill(' ')]; diff --git a/src/documents/tests/document-export.test.js b/src/documents/tests/document-export.test.js index 1c8582810..92a1ae97d 100644 --- a/src/documents/tests/document-export.test.js +++ b/src/documents/tests/document-export.test.js @@ -404,3 +404,15 @@ describe('a list numbered again part-way through', () => { expect([...markdownHtml(markdown).matchAll(//g)].map(m => m[1] ?? '1')).toEqual(['1', '1', '5']); }); }); + +describe('a list in a table cell', () => { + it('keeps its numbers and bullets in Markdown, as in plain text', () => { + const d = blankDoc(); + d.replace('x'); + d.insertTable(1, 2); + d.replace('Step one'); d.toggleList('number'); d.insertParagraph(); d.replace('Step two'); + d.selectCell(d.tables[0].rows[0].cells[1].id); d.replace('Point'); d.toggleList('bullet'); + expect(documentPlainText(d)).toContain('1.\tStep one 2.\tStep two\t•\tPoint'); + expect(documentMarkdown(d)).toContain('| 1. Step one
    2. Step two | • Point |'); + }); +}); From e87c43c60f87c1bbb9c9e87201f1b683ade9fe40 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:41:19 -0700 Subject: [PATCH 023/186] Documents: open a web page or Markdown file as it reads, not as a copy embedded in it Text copied from this editor carries a description of what was copied (data-documents-fragment), which a paste reads instead of the HTML. Opening a web page or Markdown file, or inserting one with Text from File, read it too: a page holding one (a copy pasted into a site that keeps data attributes, or a page made to show one text and open as another) opened as that description alone, dropping the rest of the page. Files are now read as they are. --- .../src/engine/clipboard-fragment.js | 10 +++-- src/documents/src/views/editor.js | 2 +- src/documents/src/views/insert-tab.js | 2 +- .../browser/import-embedded-fragment.spec.js | 41 +++++++++++++++++++ 4 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 src/documents/tests/browser/import-embedded-fragment.spec.js diff --git a/src/documents/src/engine/clipboard-fragment.js b/src/documents/src/engine/clipboard-fragment.js index d3f2fd0e6..43fa8f1c3 100644 --- a/src/documents/src/engine/clipboard-fragment.js +++ b/src/documents/src/engine/clipboard-fragment.js @@ -294,11 +294,15 @@ function fromForeignHtml(body) { return { paragraphs, foreign: true }; } -/** The fragment an HTML clipboard item holds, from this editor or converted from another application. */ -export function htmlFragment(html) { +/** + * The fragment an HTML clipboard item holds, from this editor or converted from another application. `copied`: the + * HTML may be this editor's own copy, whose description of what was copied stands for it; a file opened or inserted + * is read as it is, whatever description it holds. + */ +export function htmlFragment(html, { copied = true } = {}) { if (typeof DOMParser === 'undefined' || !html) return null; const doc = new DOMParser().parseFromString(html, 'text/html'); - const own = doc.querySelector(`[${FRAGMENT_ATTRIBUTE}]`); + const own = copied && doc.querySelector(`[${FRAGMENT_ATTRIBUTE}]`); if (own) { try { const fragment = JSON.parse(own.getAttribute(FRAGMENT_ATTRIBUTE)); diff --git a/src/documents/src/views/editor.js b/src/documents/src/views/editor.js index 178b6bb0b..ef9339a34 100644 --- a/src/documents/src/views/editor.js +++ b/src/documents/src/views/editor.js @@ -1731,7 +1731,7 @@ export function editorView({ doc, goHome, newDoc, importFile, openPath, newFromT // Pictures in a Markdown file or web page are not brought in yet: the notices say how many were left out. const omittedPictures = importedHtml && / { // In a new document every imported block keeps its own formatting. for (const p of fragment.paragraphs) p.mark = true; diff --git a/src/documents/src/views/insert-tab.js b/src/documents/src/views/insert-tab.js index b55ef3886..f8b3e4ca9 100644 --- a/src/documents/src/views/insert-tab.js +++ b/src/documents/src/views/insert-tab.js @@ -591,7 +591,7 @@ export function createInsert({ editor, pkg, error, base = {}, docName = () => '' // Without formatting (in a table cell, or a document with notes or fields) a web page's or Markdown file's // text goes in as its paragraphs, not as the markup: the raw text content of a page runs paragraphs // together and includes the text of scripts and styles, and Markdown would show its # and ** marks. - const converted = htmlFragment(kind === 'markdown' ? (await import('../lib/markdown-html.js')).markdownHtml(raw) : raw); + const converted = htmlFragment(kind === 'markdown' ? (await import('../lib/markdown-html.js')).markdownHtml(raw) : raw, { copied: false }); fragment = formatted ? converted : null; text = converted ? converted.paragraphs.map(p => p.text).join('\n') : ''; } diff --git a/src/documents/tests/browser/import-embedded-fragment.spec.js b/src/documents/tests/browser/import-embedded-fragment.spec.js new file mode 100644 index 000000000..2c494c138 --- /dev/null +++ b/src/documents/tests/browser/import-embedded-fragment.spec.js @@ -0,0 +1,41 @@ +import { test, expect } from '@playwright/test'; + +/** + * Text this editor copies carries its own description of what was copied (a data-documents-fragment attribute), which + * a paste back here reads instead of the HTML. A web page or Markdown file opened as a document is not such a copy: + * one that holds the attribute (a copy pasted into a site that keeps data attributes, then saved; or a page made to + * show one text and open as another) opens as the page reads, not as the description alone. + */ + +const input = page => page.getByRole('textbox', { name: 'Document text' }); +const EMBEDDED = `

    Quarterly report

    Revenue grew 4%.

    `; + +async function open(page, name, mimeType, source) { + await page.goto('/mock.html#/new'); + await expect(page.getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + await page.locator('input[type=file][aria-label="Open DOCX file"]').setInputFiles({ name, mimeType, buffer: Buffer.from(source) }); + await expect(page.getByRole('textbox', { name: 'Document name' })).toHaveValue(name.replace(/\.\w+$/, '')); +} + +test('a web page holding an embedded copy opens as the page reads', async ({ page }) => { + await open(page, 'page.html', 'text/html', `${EMBEDDED}`); + await expect(input(page)).toHaveValue('Quarterly report\nRevenue grew 4%.'); +}); + +test('a Markdown file holding an embedded copy opens as the file reads', async ({ page }) => { + await open(page, 'notes.md', 'text/markdown', `# Notes\n\n${EMBEDDED}\n\nLast line.\n`); + await expect(input(page)).toHaveValue('Notes\nQuarterly report\nRevenue grew 4%.\nLast line.'); +}); + +test('Text from File reads a web page holding an embedded copy as the page reads', async ({ page }) => { + await page.goto('/playground.html'); + await page.getByRole('button', { name: 'New', exact: true }).click(); + await input(page).fill('Start'); + await page.locator('#ribbon-tab-insert').click(); + await page.locator('#ribbon-panel-insert').getByRole('button', { name: 'Object', exact: true }).click(); + const chooser = page.waitForEvent('filechooser'); + await page.getByRole('menuitem', { name: 'Text from File…' }).click(); + await input(page).press('ControlOrMeta+End'); + await (await chooser).setFiles({ name: 'page.html', mimeType: 'text/html', buffer: Buffer.from(`${EMBEDDED}`) }); + await expect.poll(() => input(page).inputValue()).toMatch(/Quarterly report\nRevenue grew 4%\.$/); +}); From aacf76e9bc71ed82b468297324a623582fc1bd0c Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:42:44 -0700 Subject: [PATCH 024/186] Documents: open Russian, Ukrainian, Greek, Hebrew and Arabic text files saved in legacy code pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A text file that is not UTF-8 and reads as none of the Korean, Japanese or Chinese code pages was read as Windows-1252, so a Russian file saved by Notepad opened as "Ïðèâåò, ìèð". Windows-1251, 1253, 1255 and 1256 are now recognised when their letters make up the text beyond ASCII, stand in words of their own, and are mostly their languages' common letters; Western text still reads as Windows-1252. --- src/documents/src/lib/importers.js | 42 +++++++++++++++++++++++++++ src/documents/tests/importers.test.js | 23 +++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/documents/src/lib/importers.js b/src/documents/src/lib/importers.js index 9b5e0ca32..d6d581459 100644 --- a/src/documents/src/lib/importers.js +++ b/src/documents/src/lib/importers.js @@ -159,9 +159,51 @@ export function decodeTextFile(bytes, kind = null) { if (valid >= damaged) return utf8; const cjk = legacyCjkText(b); if (cjk != null) return cjk; + const other = legacyAlphabetText(b); + if (other != null) return other; try { return new TextDecoder('windows-1252').decode(b); } catch { return utf8; } } +/** + * The Windows code pages of the other alphabets, each with the letters written most often in its languages + * (Russian and Ukrainian; Greek; Hebrew; Arabic). Their letters sit on the bytes Windows-1252 gives to accented + * Latin letters. + */ +const LEGACY_ALPHABETS = [ + { encoding: 'windows-1251', script: /\p{Script=Cyrillic}/u, common: new Set('оеаинтсрвлкмдпуі') }, + { encoding: 'windows-1253', script: /\p{Script=Greek}/u, common: new Set('αοεινστηκρπυμλάέήίόύώ') }, + { encoding: 'windows-1255', script: /\p{Script=Hebrew}/u, common: new Set('יוהלאמרבתנשעכד') }, + { encoding: 'windows-1256', script: /\p{Script=Arabic}/u, common: new Set('الينمورتبعهدفك') }, +]; + +/** + * The text of a file in the Windows code page of a Cyrillic, Greek, Hebrew or Arabic text (as Notepad saved it + * before Unicode); null when the bytes do not read as one. Read in Windows-1252, such a text is accented Latin + * letters throughout ("Ïðèâåò" for "Привет"). An encoding is taken only when its letters make up the text beyond + * ASCII, stand in words of their own rather than among Latin letters (as a Western text's accented letters do, + * "café"), and are mostly its language's common ones. A short text says too little and stays Windows-1252. + */ +function legacyAlphabetText(b) { + let best = null; + for (const { encoding, script, common } of LEGACY_ALPHABETS) { + let text; + try { text = new TextDecoder(encoding, { fatal: true }).decode(b); } catch { continue; } + const sample = text.slice(0, 65536); + let wide = 0, own = 0, usual = 0; + for (const ch of sample) { + if (ch <= '\x7f') continue; + wide++; + if (script.test(ch)) { own++; if (common.has(ch.toLowerCase())) usual++; } + } + if (own < 8 || own / wide < 0.8) continue; + const words = sample.split(/[^\p{L}\p{M}]+/u).filter((word) => script.test(word)); + if (words.filter((word) => /[A-Za-z]/.test(word)).length > words.length * 0.1) continue; + const score = usual / own; + if (score >= 0.3 && (!best || score > best.score)) best = { text, score }; + } + return best ? best.text : null; +} + // The characters written most often in Korean and in Chinese text. Text in the other language, or in a // Western code page, read in the wrong encoding comes out as valid but rarely used characters. const KOREAN_COMMON = new Set('이다는의에하고을가지를로한기서도사으리자어나인일대수정시부전것보요해그면라아들주게국있제만적'); diff --git a/src/documents/tests/importers.test.js b/src/documents/tests/importers.test.js index 3ad6f125b..e58fc4961 100644 --- a/src/documents/tests/importers.test.js +++ b/src/documents/tests/importers.test.js @@ -116,6 +116,29 @@ describe('legacy Korean, Japanese and Chinese text', () => { }); }); +describe('legacy Cyrillic, Greek, Hebrew and Arabic text', () => { + const encode = (text, encoding) => { + // The code page's byte for each character, found by decoding every byte (TextDecoder only decodes). + const table = new Map([...new TextDecoder(encoding).decode(Uint8Array.from({ length: 256 }, (_, i) => i))].map((ch, i) => [ch, i])); + return new Uint8Array([...text].map(ch => table.get(ch))); + }; + it('reads a file saved in its language\'s Windows code page as its text, not as accented Latin letters', () => { + for (const [text, encoding] of [ + ['Привет, мир! Это обычный текстовый файл, сохранённый в Блокноте.', 'windows-1251'], + ['Доброго ранку! Це звичайний текст українською мовою.', 'windows-1251'], + ['Καλημέρα κόσμε. Αυτό είναι ένα απλό κείμενο στα ελληνικά.', 'windows-1253'], + ['שלום עולם. זהו קובץ טקסט פשוט בעברית.', 'windows-1255'], + ['مرحبا بالعالم. هذا ملف نصي بسيط باللغة العربية.', 'windows-1256'], + ]) expect(decodeTextFile(encode(text, encoding))).toBe(text); + }); + it('still reads Western text, with a few accented letters in its words, as Windows-1252', () => { + for (const text of ['Le cœur a ses raisons que la raison ne connaît point. Ça va très bien, déjà vu, à côté.', + 'Größe, Straße, übermäßig schön; Fußgänger über die Brücke.', 'Ångström, Æsir, Øresund — Ærø är så vackert.']) { + expect(decodeTextFile(encode(text, 'windows-1252'))).toBe(text); + } + }); +}); + describe('applyImport', () => { const fakeApi = ({ blank = true } = {}) => { const calls = []; From b75049e11a54f513164835c2ba2ac1de572387a9 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:43:28 -0700 Subject: [PATCH 025/186] Documents: keep links whose address has spaces or line breaks around it in opened web pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A web page or pasted HTML link written href=" https://… " (or with a line break before the address) works in a browser, which leaves that whitespace out, but lost its link here: the address had to start with https:, mailto: or tel: as written. --- src/documents/src/engine/clipboard-fragment.js | 6 +++++- src/documents/tests/clipboard-fragment.test.js | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/documents/src/engine/clipboard-fragment.js b/src/documents/src/engine/clipboard-fragment.js index 43fa8f1c3..c12e00657 100644 --- a/src/documents/src/engine/clipboard-fragment.js +++ b/src/documents/src/engine/clipboard-fragment.js @@ -241,7 +241,11 @@ function fromForeignHtml(body) { if (tag === 'BR') { if (!node.classList?.contains('Apple-interchange-newline')) append('\v', format, context.link); return; } const next = inherited(node, format); const ctx = { ...context }; - if (tag === 'A' && /^(https?:|mailto:|tel:)/i.test(node.getAttribute('href') || '')) ctx.link = node.getAttribute('href'); + if (tag === 'A') { + // A browser follows an address with the spaces and line breaks around it (and tabs and line breaks in it) left out. + const href = (node.getAttribute('href') || '').replace(/^[\u0000-\u0020]+|[\u0000-\u0020]+$/g, '').replace(/[\t\n\r]/g, ''); + if (/^(https?:|mailto:|tel:)/i.test(href)) ctx.link = href; + } // Word writes a tab as no-break spaces styled mso-tab-count, and a run of spaces as no-break spaces styled // mso-spacerun, so a browser shows their width: they are a tab and ordinary spaces. const declared = node.getAttribute?.('style') || '', tabs = /mso-tab-count\s*:\s*(\d+)/i.exec(declared)?.[1]; diff --git a/src/documents/tests/clipboard-fragment.test.js b/src/documents/tests/clipboard-fragment.test.js index 002feb012..d2e0b1069 100644 --- a/src/documents/tests/clipboard-fragment.test.js +++ b/src/documents/tests/clipboard-fragment.test.js @@ -92,3 +92,11 @@ it('writes tel: and mailto: links into the copied HTML, and never unsafe schemes const anchors = [...new DOMParser().parseFromString(html, 'text/html').querySelectorAll('a')].map(a => [a.textContent, a.getAttribute('href')]); expect(anchors).toEqual([['Call', 'tel:+1-555-0100'], [' mail', 'mailto:a@example.com']]); }); + +it('keeps a link whose address has spaces or line breaks around it, as a browser follows it', () => { + const links = html => htmlFragment(html)?.paragraphs.flatMap(p => p.runs.filter(r => r.link).map(r => [r.text, r.link])); + expect(links('

    See the site.

    ')).toEqual([['the site', 'https://example.com/a']]); + expect(links('

    Write

    ')).toEqual([['Write', 'mailto:someone@example.com']]); + // An address that is not a web, mail or phone link still gives no link. + expect(links('

    No

    ')).toEqual([]); +}); From 4415a0aacaf5ff7e8f0eab4274abcc72f302e38e Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:46:28 -0700 Subject: [PATCH 026/186] Documents: make links clickable in exported PDFs Save as PDF wrote each page as a picture with a text layer and no link annotations, so web, mail and phone links in the document could not be followed in the PDF, as they can in one a word processor exports. Each line (or picture) a link covers is now a link annotation with its address. --- src/documents/src/lib/pdf-export.js | 44 ++++++++++++++++++++++++-- src/documents/tests/pdf-export.test.js | 39 ++++++++++++++++++++++- 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/src/documents/src/lib/pdf-export.js b/src/documents/src/lib/pdf-export.js index b13885c83..e7e4f912b 100644 --- a/src/documents/src/lib/pdf-export.js +++ b/src/documents/src/lib/pdf-export.js @@ -6,8 +6,12 @@ * and whose ToUnicode map returns the original characters. */ +import { selectionRects } from '../engine/layout.js'; + const encoder = new TextEncoder(); const PT_PER_PX = 0.75; +/** Link targets a PDF reader may follow, as the web page export writes them. */ +const SAFE_LINK = /^(https?:|mailto:|tel:)/i; /** UTF-16BE hex for a PDF hex string. */ export function utf16Hex(text) { @@ -100,13 +104,21 @@ export function buildPdf(pages, { title = 'Document', author = '' } = {}) { define(cidFont, `<< /Type /Font /Subtype /CIDFontType2 /BaseFont /DocumentsTextLayer /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor ${descriptor} 0 R /DW 500 /CIDToGIDMap /Identity >>`); define(font, `<< /Type /Font /Subtype /Type0 /BaseFont /DocumentsTextLayer /Encoding /Identity-H /DescendantFonts [${cidFont} 0 R] /ToUnicode ${toUnicode} 0 R >>`); const kids = []; + // A link's address is a 7-bit string (§12.6.4.7): characters beyond ASCII, spaces and controls are percent-encoded. + const uri = target => `(${String(target).replace(/[^\x21-\x7e]/gu, ch => encodeURIComponent(ch)).replace(/[\\()]/g, m => '\\' + m)})`; for (const page of pages) { const pageId = reserve(), image = reserve(), content = reserve(); kids.push(pageId); const w = page.widthPx * PT_PER_PX, h = page.heightPx * PT_PER_PX; + // Each linked area is a link annotation (§12.5.6.5) with a URI action, without a border of its own. + const annots = (page.links || []).map(link => { + const id = reserve(), pt = n => (n * PT_PER_PX).toFixed(2); + define(id, `<< /Type /Annot /Subtype /Link /Rect [${pt(link.x)} ${pt(page.heightPx - link.y - link.height)} ${pt(link.x + link.width)} ${pt(page.heightPx - link.y)}] /Border [0 0 0] /A << /S /URI /URI ${uri(link.target)} >> >>`); + return id; + }); define(image, { dict: `/Type /XObject /Subtype /Image /Width ${page.imageWidth} /Height ${page.imageHeight} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode`, stream: page.jpeg }); define(content, { stream: encoder.encode(`q ${w.toFixed(2)} 0 0 ${h.toFixed(2)} 0 0 cm /Im1 Do Q\n${textLayer(page.fragments, page.heightPx, astral)}`) }); - define(pageId, `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${w.toFixed(2)} ${h.toFixed(2)}] /Resources << /XObject << /Im1 ${image} 0 R >> /Font << /F1 ${font} 0 R >> >> /Contents ${content} 0 R >>`); + define(pageId, `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${w.toFixed(2)} ${h.toFixed(2)}] /Resources << /XObject << /Im1 ${image} 0 R >> /Font << /F1 ${font} 0 R >> >> /Contents ${content} 0 R${annots.length ? ` /Annots [${annots.map(id => `${id} 0 R`).join(' ')}]` : ''} >>`); } // Written once every page's text has met its characters beyond the BMP. define(toUnicode, { stream: encoder.encode(toUnicodeMap(astral)) }); @@ -145,6 +157,32 @@ export function pageFragments(lines, page) { return out; } +/** + * The areas of the page where a web, mail or phone link's text is laid out: [{ page, x, y, width, height, target }] + * in page pixels, one per line (or picture) the link covers. Links within the document (to a bookmark) are left out. + */ +export function pageLinks(paragraphs, layout) { + const ranges = []; + let at = 0; + for (const p of paragraphs) { + for (const run of p.runs) { + const length = String(run.text || '').length, target = run.link?.target; + if (typeof target === 'string' && SAFE_LINK.test(target) && !run.vanish) { + const last = ranges.at(-1); + if (last?.target === target && last.end === at) last.end += length; + else ranges.push({ start: at, end: at + length, target }); + } + at += length; + } + at++; + } + const out = []; + for (const { start, end, target } of ranges) { + for (const r of selectionRects(layout, start, end)) if (r.width > 0 && r.height > 0) out.push({ page: r.page ?? 0, x: r.x, y: r.y, width: r.width, height: r.height, target }); + } + return out; +} + const jpegOf = canvas => new Promise((resolve, reject) => canvas.toBlob(blob => blob ? blob.arrayBuffer().then(b => resolve(new Uint8Array(b)), reject) : reject(new Error('The page could not be encoded.')), 'image/jpeg', 0.92)); /** Export the editor's document as a PDF Blob. */ @@ -155,9 +193,11 @@ export async function exportPdf(engine, { title } = {}) { const { layout, storyLayout } = engine.printedLayout || engine; const lines = [...layout.lines, ...(layout.repeatedLines || []), ...(storyLayout?.lines || [])]; const pages = []; + let links = []; + try { links = pageLinks(engine.model.paragraphs, layout); } catch (e) { console.warn('[documents] the PDF\'s links could not be placed', e); } for (const [index, canvas] of sheets.entries()) { const geometry = layout.pages[index] || layout.page; - pages.push({ widthPx: geometry.width, heightPx: geometry.height, jpeg: await jpegOf(canvas), imageWidth: canvas.width, imageHeight: canvas.height, fragments: pageFragments(lines, index) }); + pages.push({ widthPx: geometry.width, heightPx: geometry.height, jpeg: await jpegOf(canvas), imageWidth: canvas.width, imageHeight: canvas.height, fragments: pageFragments(lines, index), links: links.filter(link => link.page === index) }); } return new Blob([buildPdf(pages, { title })], { type: 'application/pdf' }); } diff --git a/src/documents/tests/pdf-export.test.js b/src/documents/tests/pdf-export.test.js index bc53894a0..06a461a1f 100644 --- a/src/documents/tests/pdf-export.test.js +++ b/src/documents/tests/pdf-export.test.js @@ -1,5 +1,5 @@ import { it, expect } from 'vitest'; -import { buildPdf, pageFragments, textLayer, utf16Hex } from '../src/lib/pdf-export.js'; +import { buildPdf, pageFragments, pageLinks, textLayer, utf16Hex } from '../src/lib/pdf-export.js'; // A 1×1 baseline JPEG (independently written bytes). const JPEG = Uint8Array.from(atob('/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/yQALCAABAAEBAREA/8wABgAQEAX/2gAIAQEAAD8A0s8g/9k='), c => c.charCodeAt(0)); @@ -76,3 +76,40 @@ it('puts list numbers and bullets in the searchable text, before their item\'s t expect(number.width).toBeGreaterThan(0); expect(number.fontSize).toBeGreaterThan(0); }); + +it('makes web, mail and phone links clickable where their text is on the page', async () => { + const { TextDocument, paragraph } = await import('../src/engine/model.js'); + const { layoutDocument } = await import('../src/engine/layout.js'); + const measure = text => ({ width: [...text].length * 8, ascent: 12, descent: 3 }); + const doc = new TextDocument([paragraph()]); + doc.replace('Read the guide now. '); + doc.select(9, 14); doc.setHyperlink('https://example.com/a(b)?q=ü%20x'); + doc.select(doc.text.length); doc.replace('Mail us'); + doc.select(doc.text.length - 7, doc.text.length); doc.setHyperlink('mailto:team@example.com'); + doc.select(doc.text.length); doc.insertParagraph(); doc.replace('Unsafe'); + doc.select(doc.text.length - 6, doc.text.length); + try { doc.setHyperlink('javascript:alert(1)'); } catch { /* refused */ } + const layout = layoutDocument(doc.paragraphs, measure, undefined, doc.numbering, doc.tables); + const links = pageLinks(doc.paragraphs, layout); + expect(links.map(l => [l.page, l.target])).toEqual([[0, 'https://example.com/a(b)?q=ü%20x'], [0, 'mailto:team@example.com']]); + const [guide] = links, fragment = layout.lines[0].fragments.find(f => f.text.includes('guide')); + // The area covers the linked word on its line: "guide" starts 9 characters (72 px) into the line. + expect(guide.x).toBeCloseTo(layout.lines[0].fragments[0].x + 72, 0); + expect(guide.width).toBeCloseTo(40, 0); + expect(guide.y).toBeLessThanOrEqual(layout.lines[0].baseline - 12); + expect(guide.y + guide.height).toBeGreaterThanOrEqual(layout.lines[0].baseline); + expect(fragment).toBeTruthy(); + const page = { widthPx: 816, heightPx: 1056, jpeg: JPEG, imageWidth: 1, imageHeight: 1, fragments: [], links: links.filter(l => l.page === 0) }; + const text = new TextDecoder('latin1').decode(buildPdf([page])); + expect(text.match(/\/Subtype \/Link/g)).toHaveLength(2); + // An address is written as ASCII, with its brackets escaped, and points at its text (PDF points from the bottom). + expect(text).toContain('/URI (https://example.com/a\\(b\\)?q=%C3%BC%20x)'); + expect(text).toContain('/URI (mailto:team@example.com)'); + const rect = /\/Rect \[([\d. ]+)\][^]*?https:\/\/example/.exec(text)[1].split(' ').map(Number); + expect(rect[0]).toBeCloseTo(guide.x * 0.75, 1); + expect(rect[3]).toBeCloseTo((1056 - guide.y) * 0.75, 1); + expect(text).toMatch(/\/Annots \[\d+ 0 R \d+ 0 R\]/); + // The cross-reference table still finds every object. + const xref = Number(/startxref\n(\d+)/.exec(text)[1]); + [...text.slice(xref).matchAll(/^(\d{10}) 00000 n $/gm)].map(m => Number(m[1])).forEach((offset, i) => expect(text.slice(offset, offset + `${i + 1} 0 obj`.length)).toBe(`${i + 1} 0 obj`)); +}); From 2efad96bb91af05cb5a50f89034d96f60510cf81 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:48:49 -0700 Subject: [PATCH 027/186] Documents: copy and search right-to-left text in exported PDFs in reading order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PDF text layer held each run's characters in reading order, left to right from the run's start. PDF readers take a page's glyphs as they stand from left to right and put right-to-left text back in reading order, so Hebrew and Arabic copied from an exported PDF came out reversed ("םולש" for "שלום") and could not be found. The text layer now holds them in the order they are painted, with brackets mirrored and each letter's marks kept. --- src/documents/src/lib/pdf-export.js | 23 +++++++++++++++++++++-- src/documents/tests/pdf-export.test.js | 25 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/documents/src/lib/pdf-export.js b/src/documents/src/lib/pdf-export.js index e7e4f912b..d0934719a 100644 --- a/src/documents/src/lib/pdf-export.js +++ b/src/documents/src/lib/pdf-export.js @@ -7,6 +7,7 @@ */ import { selectionRects } from '../engine/layout.js'; +import { bidiEmbeddingLevels, bidiMirroredCharacter, bidiVisualOrder } from '../engine/bidi-text.js'; const encoder = new TextEncoder(); const PT_PER_PX = 0.75; @@ -39,12 +40,30 @@ function codeHex(text, astral) { return hex.toUpperCase(); } +const graphemes = text => (typeof Intl?.Segmenter === 'function' ? [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(text)].map(s => s.segment) : text.match(/\P{M}\p{M}*|\p{M}+/gu) || []); +const STRONG_RTL = /[\u0590-\u08ff\ufb1d-\ufdff\ufe70-\ufefc]|\ud802[\udc00-\udfff]|\ud803[\udc00-\ude7f]|\ud83a[\udc00-\udfff]/; + +/** + * A run's text in the order its glyphs stand on the page, left to right, as a PDF holds them: readers put + * right-to-left text back in reading order. A run the layout gave a direction (`rtl`) is painted in it throughout; + * one without is painted in the paragraph's left-to-right order, with any right-to-left words in it reversed by the + * bidirectional algorithm. Each letter keeps its marks, and a bracket in right-to-left text its mirrored form. + */ +export function visualText(text, rtl = false) { + if (!rtl && !STRONG_RTL.test(text)) return text; + const levels = rtl ? null : bidiEmbeddingLevels(text, 'ltr'); + let at = 0; + const clusters = graphemes(text).map(segment => { const level = rtl ? 1 : levels[at]; at += segment.length; return { segment, level }; }); + return bidiVisualOrder(clusters, c => c.level).map(c => (c.level % 2 ? c.segment.replace(/^./u, ch => bidiMirroredCharacter(ch)) : c.segment)).join(''); +} + /** Text-layer operators for one page: lines of [{ text, x, baseline, width, fontSize }] in page pixels. */ export function textLayer(fragments, pageHeightPx, astral = new Map()) { const ops = ['BT', '3 Tr']; for (const f of fragments) { - const text = f.text.replace(/[\u0000-\u001f­]/g, ' '); + let text = f.text.replace(/[\u0000-\u001f­]/g, ' '); if (!text.trim()) continue; + text = visualText(text, !!f.rtl); const size = Math.max(1, f.fontSize || 12), width = Math.max(1, f.width * PT_PER_PX); // Each code advances half an em in the text font; horizontal scaling fits the run's painted width. const scale = Math.max(1, Math.min(10000, width / ([...text].length * size * 0.5) * 100)); @@ -152,7 +171,7 @@ export function pageFragments(lines, page) { // A list item's number or bullet is painted apart from its text, and is read with it: "1. First step". const marker = line.marker; if (marker?.text && marker.width > 0) out.push({ text: marker.text, x: marker.x, baseline: line.baseline, width: marker.width, fontSize: marker.fontSize }); - for (const f of line.fragments || []) if (f.text && !f.image) out.push({ text: f.text, x: f.x, baseline: line.baseline, width: f.width, fontSize: f.fontSize }); + for (const f of line.fragments || []) if (f.text && !f.image) out.push({ text: f.text, x: f.x, baseline: line.baseline, width: f.width, fontSize: f.fontSize, ...(f.bidiDirection === 'rtl' ? { rtl: true } : {}) }); } return out; } diff --git a/src/documents/tests/pdf-export.test.js b/src/documents/tests/pdf-export.test.js index 06a461a1f..7a5b72161 100644 --- a/src/documents/tests/pdf-export.test.js +++ b/src/documents/tests/pdf-export.test.js @@ -113,3 +113,28 @@ it('makes web, mail and phone links clickable where their text is on the page', const xref = Number(/startxref\n(\d+)/.exec(text)[1]); [...text.slice(xref).matchAll(/^(\d{10}) 00000 n $/gm)].map(m => Number(m[1])).forEach((offset, i) => expect(text.slice(offset, offset + `${i + 1} 0 obj`.length)).toBe(`${i + 1} 0 obj`)); }); + +it('writes right-to-left text in the order it is painted, so readers copy and search it in reading order', async () => { + const { paragraph } = await import('../src/engine/model.js'); + const { RUN_FORMAT } = await import('../src/engine/formatting.js'); + const { layoutDocument } = await import('../src/engine/layout.js'); + const measure = text => ({ width: [...text].length * 8, ascent: 12, descent: 3 }); + const p = paragraph(''); + p.runs = [{ ...RUN_FORMAT, text: 'שָׁלוֹם', rtl: true }]; + const line = layoutDocument([p], measure).lines[0]; + const fragments = pageFragments([line], 0); + expect(fragments.map(f => [f.text, f.rtl])).toEqual([['שָׁלוֹם', true]]); + // A PDF holds glyphs left to right as they stand on the page: the last letter first, each with its marks. + const ops = textLayer(fragments, 1056); + const visual = ['ם', 'וֹ', 'ל', 'שָׁ'].join(''); + expect(ops).toContain(`<${utf16Hex(visual)}> Tj`); + expect(textLayer([{ text: 'abc', x: 0, baseline: 10, width: 24, fontSize: 12 }], 1056)).toContain(`<${utf16Hex('abc')}> Tj`); +}); + +it('writes right-to-left words inside left-to-right text in the order they are painted', async () => { + const { visualText } = await import('../src/lib/pdf-export.js'); + expect(visualText('Hello world')).toBe('Hello world'); + expect(visualText('שלום עולם 2024 and Word')).toBe('2024 םלוע םולש and Word'); + // Brackets in right-to-left text are painted mirrored; numbers after it keep their own order. + expect(visualText('ראה (כאן) 12')).toBe('12 (ןאכ) האר'); +}); From 02ff95c46853234d5f318e37bc9070e70fe8ce95 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:50:51 -0700 Subject: [PATCH 028/186] Documents: keep table rows together when opening web pages from Word, LibreOffice and Google Docs A web page's table becomes one tab-separated paragraph per row. Word, LibreOffice and Google Docs put a paragraph inside every cell, and each of those started a paragraph of its own, so a two-column table opened (or pasted) as one cell per line and its rows were lost. A cell's paragraphs are now text of its row, one after another. --- src/documents/src/engine/clipboard-fragment.js | 9 ++++++++- src/documents/tests/clipboard-fragment.test.js | 6 ++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/documents/src/engine/clipboard-fragment.js b/src/documents/src/engine/clipboard-fragment.js index c12e00657..7a7e78040 100644 --- a/src/documents/src/engine/clipboard-fragment.js +++ b/src/documents/src/engine/clipboard-fragment.js @@ -152,6 +152,8 @@ export function htmlPicture(html) { /* ---------- HTML from this editor or another application ---------- */ const BLOCKS = new Set(['P', 'DIV', 'CENTER', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'BLOCKQUOTE', 'PRE', 'TR', 'TABLE', 'UL', 'OL', 'SECTION', 'ARTICLE', 'HEADER', 'FOOTER', 'ASIDE', 'NAV', 'MAIN', 'ADDRESS', 'FIGURE', 'FIGCAPTION', 'DL', 'DT', 'DD', 'HR', 'TBODY', 'THEAD', 'TFOOT', 'BODY']); +/** Blocks in a table cell that keep their own lines: lists, and the rows of a table nested in it. */ +const CELL_STRUCTURE = new Set(['LI', 'UL', 'OL', 'TABLE', 'TBODY', 'THEAD', 'TFOOT', 'TR', 'DL', 'HR']); const SKIPPED = new Set(['SCRIPT', 'STYLE', 'HEAD', 'TITLE', 'META', 'LINK', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'IMG', 'VIDEO', 'AUDIO', 'CANVAS', 'IFRAME', 'OBJECT', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']); const NAMED_COLORS = { black: '000000', white: 'FFFFFF', red: 'FF0000', green: '008000', blue: '0000FF', yellow: 'FFFF00', gray: '808080', grey: '808080', orange: 'FFA500', purple: '800080' }; @@ -268,7 +270,12 @@ function fromForeignHtml(body) { } // A list item's own paragraph wrapper (as some producers write) is the item, not another paragraph. const wrapper = (tag === 'P' || tag === 'DIV') && current?.list && !current.text && context.item === current; - const block = BLOCKS.has(tag) && !wrapper; + // A table row is one paragraph, its cells apart by tabs: the paragraphs Word, LibreOffice and Google Docs write in + // every cell are text of the row, one after another, not paragraphs of their own. + if (tag === 'TD' || tag === 'TH') ctx.cell = true; + const cellBlock = context.cell && current && BLOCKS.has(tag) && !CELL_STRUCTURE.has(tag); + if (cellBlock && current.text && !/[\t ]$/.test(current.text)) append(' ', format, null); + const block = BLOCKS.has(tag) && !wrapper && !cellBlock; if (block) { end(); if (!['UL', 'OL', 'TABLE', 'TBODY', 'THEAD', 'TFOOT', 'DL', 'BODY'].includes(tag)) { diff --git a/src/documents/tests/clipboard-fragment.test.js b/src/documents/tests/clipboard-fragment.test.js index d2e0b1069..ecefc5b8e 100644 --- a/src/documents/tests/clipboard-fragment.test.js +++ b/src/documents/tests/clipboard-fragment.test.js @@ -100,3 +100,9 @@ it('keeps a link whose address has spaces or line breaks around it, as a browser // An address that is not a web, mail or phone link still gives no link. expect(links('

    No

    ')).toEqual([]); }); + +it('keeps a table row on one line when its cells hold paragraphs, as Word, LibreOffice and Google Docs write them', () => { + expect(texts('

    Name

    Qty

    Apples

    3

    ')).toEqual(['Name\tQty', 'Apples\t3']); + expect(texts('

    Item

    Two
    lines

    After

    ')).toEqual(['Item\tTwo lines', 'After']); + expect(texts('

    Head

    Plain
    ')).toEqual(['Head\tPlain']); +}); From a0e19afdc39ffdc8c0993143df6d0e769b4b05b6 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:54:02 -0700 Subject: [PATCH 029/186] Documents: number lists from Markdown files and web pages as they are numbered there Each list item of another application's HTML (and of Markdown, which opens through it) was made a list item on its own, joining the paragraph above when that was one. Steps with code or a second paragraph under each item (as READMEs write them) all came out as "1.", a list's start number was ignored, and two lists one after another ran on as one. Each list now numbers on through what its items hold, starts at its own number, and starts again after another list. --- .../src/engine/clipboard-fragment.js | 32 +++++++++++++++-- .../tests/clipboard-fragment.test.js | 2 +- .../tests/import-list-numbers.test.js | 34 +++++++++++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 src/documents/tests/import-list-numbers.test.js diff --git a/src/documents/src/engine/clipboard-fragment.js b/src/documents/src/engine/clipboard-fragment.js index 7a7e78040..aba5e848a 100644 --- a/src/documents/src/engine/clipboard-fragment.js +++ b/src/documents/src/engine/clipboard-fragment.js @@ -201,6 +201,7 @@ function wordListItem(el) { function fromForeignHtml(body) { const paragraphs = []; + let lists = 0; let current = null; const start = (props = {}) => { current = { text: '', runs: [], mark: true, styleId: null, styleName: null, alignment: null, list: null, ...props }; paragraphs.push(current); }; const append = (text, format, link, pre = false) => { @@ -263,7 +264,12 @@ function fromForeignHtml(body) { // cells of a table centered by its container (as Word writes one) keep their own alignment. const align = node.style?.textAlign || (tag !== 'TABLE' && node.getAttribute('align')) || (tag === 'CENTER' && 'center'); if (align || tag === 'TABLE') ctx.align = { center: 'center', right: 'right', justify: 'both' }[align && align.toLowerCase()] || null; - if (tag === 'UL' || tag === 'OL') ctx.list = { kind: tag === 'UL' ? 'bullet' : 'number', level: context.list ? Math.min(8, context.list.level + 1) : 0 }; + // Each list is told apart (`id`) so its items number on past paragraphs and lists inside them, and a list right + // after another starts again; an ordered list keeps the number it starts at. + if (tag === 'UL' || tag === 'OL') { + const start = tag === 'OL' ? Number.parseInt(node.getAttribute('start') ?? '', 10) : NaN; + ctx.list = { kind: tag === 'UL' ? 'bullet' : 'number', level: context.list ? Math.min(8, context.list.level + 1) : 0, id: ++lists, ...(Number.isSafeInteger(start) && start >= 0 && start <= 32767 ? { start } : {}) }; + } if ((tag === 'TD' || tag === 'TH') && current?.text && !/\t$/.test(current.text)) { const last = current.runs.at(-1); last.text = last.text.replace(/ +$/, ''); current.text = current.text.replace(/ +$/, ''); append('\t', format, null); @@ -335,7 +341,25 @@ function styleFor(model, p) { return Object.values(definitions).find(s => s.type === 'paragraph' && !s.error && String(s.name || '').toLowerCase() === name)?.id || null; } -function applyParagraph(model, p, foreign) { +/** + * The item of a list another application wrote (`p.list.id`): it goes on in the list its earlier items went into here, + * though a paragraph or another list came between, and the first item of a list starts it again at its number when + * the list goes on from one pasted before it, or starts at another number. `lists`: each list's numbering so far. + */ +function continueList(model, p, lists) { + const target = model.selectedParagraphs[0]; + if (!target?.list || p.list?.id == null) return; + const known = lists.get(p.list.id); + if (known != null) { + if (target.list.numId !== known) { target.list = { ...target.list, numId: known, direct: true }; target.listChanged = true; } + return; + } + const joined = [...lists.values()].includes(target.list.numId); + if (p.list.kind === 'number' && (joined || (p.list.start ?? 1) !== 1)) attempt(() => model.restartNumbering(p.list.start ?? 1)); + lists.set(p.list.id, model.selectedParagraphs[0].list?.numId); +} + +function applyParagraph(model, p, foreign, lists = new Map()) { const style = styleFor(model, p); if (style) attempt(() => model.setParagraphStyle(style)); else if (!foreign && p.styleId === null) attempt(() => model.setParagraphStyle('')); @@ -347,6 +371,7 @@ function applyParagraph(model, p, foreign) { attempt(() => model.toggleList(p.list.kind)); } for (let i = 0; i < 8 && model.selectedParagraphs[0]?.list && model.selectedParagraphs[0].list.level < p.list.level; i++) attempt(() => model.changeListLevel(1)); + continueList(model, p, lists); } else if (current === 'bullet' || current === 'number') attempt(() => model.toggleList(current)); } @@ -406,8 +431,9 @@ export function formattedPasteFits(fragment) { function applyFragment(model, fragment, start) { const after = start + fragment.paragraphs.reduce((n, p) => n + p.text.length + 1, -1); let at = start; + const lists = new Map(); for (const p of fragment.paragraphs) { - if (p.mark) { model.select(at); applyParagraph(model, p, !!fragment.foreign); } + if (p.mark) { model.select(at); applyParagraph(model, p, !!fragment.foreign, lists); } let offset = at; const applied = new Set(appliedRuns(fragment, p)); for (const run of p.runs) { if (applied.has(run)) applyRun(model, offset, offset + run.text.length, run, !!fragment.foreign); offset += run.text.length; } diff --git a/src/documents/tests/clipboard-fragment.test.js b/src/documents/tests/clipboard-fragment.test.js index ecefc5b8e..f7b06ecf2 100644 --- a/src/documents/tests/clipboard-fragment.test.js +++ b/src/documents/tests/clipboard-fragment.test.js @@ -37,7 +37,7 @@ it('pastes Word\'s tabs and runs of spaces as a tab and spaces, not no-break spa it('marks headings and nested lists, and keeps a lone phrase in the destination paragraph', () => { const fragment = htmlFragment('

    Title

    1. One
      • Inner
    '); expect(fragment.paragraphs.map(p => [p.text, p.styleName, p.list, p.mark])).toEqual([ - ['Title', 'Heading 3', null, true], ['One', 'Normal', { kind: 'number', level: 0 }, true], ['Inner', 'Normal', { kind: 'bullet', level: 1 }, true]]); + ['Title', 'Heading 3', null, true], ['One', 'Normal', { kind: 'number', level: 0, id: 1 }, true], ['Inner', 'Normal', { kind: 'bullet', level: 1, id: 2 }, true]]); expect(htmlFragment('

    Just a phrase

    ').paragraphs[0].mark).toBe(false); }); diff --git a/src/documents/tests/import-list-numbers.test.js b/src/documents/tests/import-list-numbers.test.js new file mode 100644 index 000000000..2bef10c31 --- /dev/null +++ b/src/documents/tests/import-list-numbers.test.js @@ -0,0 +1,34 @@ +// @vitest-environment jsdom +import { it, expect } from 'vitest'; +import { TextDocument, paragraph } from '../src/engine/model.js'; +import { htmlFragment, pasteFragment } from '../src/engine/clipboard-fragment.js'; +import { markdownHtml } from '../src/lib/markdown-html.js'; +import { documentPlainText } from '../src/lib/document-export.js'; + +/** A Markdown file or web page opened as a new document, as the editor opens one, and its text with list markers. */ +const opened = (html) => { + const doc = new TextDocument([paragraph()]), fragment = htmlFragment(html, { copied: false }); + for (const p of fragment.paragraphs) p.mark = true; + pasteFragment(doc, fragment); + return documentPlainText(doc).trim().split('\n'); +}; + +it('numbers on through a list whose items hold code or more paragraphs, as READMEs write steps', () => { + expect(opened(markdownHtml('1. Install\n\n ```\n npm i\n ```\n\n2. Build\n\n More about building.\n\n3. Run\n'))).toEqual(['1.\tInstall', 'npm i', '2.\tBuild', 'More about building.', '3.\tRun']); +}); + +it('starts a list at the number it gives', () => { + expect(opened(markdownHtml('5. five\n6. six\n'))).toEqual(['5.\tfive', '6.\tsix']); + expect(opened('
    1. c
    2. d
    ')).toEqual(['3.\tc', '4.\td']); +}); + +it('keeps two lists one after another apart', () => { + expect(opened('
    1. a
    2. b
    1. x
    2. y
    ')).toEqual(['1.\ta', '2.\tb', '1.\tx', '2.\ty']); + expect(opened(markdownHtml('1. a\n2. b\n\n---\n\n1. x\n2. y\n'))).toEqual(['1.\ta', '2.\tb', '1.\tx', '2.\ty']); +}); + +it('goes on numbering an outer list after a list nested in its item', () => { + const [a, point, b] = opened('
    1. a
      • point
    2. b
    '); + expect([a, b]).toEqual(['1.\ta', '2.\tb']); + expect(point).toMatch(/^ \S\tpoint$/); +}); From c6e974668e9facc295995c8aa37893dd6b0f8c5d Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:56:03 -0700 Subject: [PATCH 030/186] Documents: keep the owner's tab saving a shared document whatever a guest's client claims The owner's tabs choose which of them writes the file by the presence entries that say they save. Any entry saying so from a session that sorted first made the owner's tab stop saving, though only an owner may write the file: a guest's modified client could stop the file being saved, leaving everyone's changes only in the room. Only entries the service stamps with the owner's role count now. --- src/documents/src/collab/live.js | 4 +++- src/documents/tests/collab-saver-handoff.test.js | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/documents/src/collab/live.js b/src/documents/src/collab/live.js index beddee2c8..306682aa3 100644 --- a/src/documents/src/collab/live.js +++ b/src/documents/src/collab/live.js @@ -129,7 +129,9 @@ export class LiveSession extends EventTarget { elect() { const mayWrite = !this.local && this.role === 'owner' && !!this.doc.path; let saves = mayWrite; - if (mayWrite) for (const person of this.client.roster) if (person.saves && person.session < this.client.session) saves = false; + // Only another owner's tab can save the file: the service stamps each presence with its sender's role, so a + // guest's client claiming to save cannot stop the owner's tab writing everyone's changes. + if (mayWrite) for (const person of this.client.roster) if (person.saves && person.role === 'owner' && person.session < this.client.session) saves = false; if (this.saves !== saves) { this.saves = saves; // Local sharing has no file: each tab keeps its own download, as before sharing. diff --git a/src/documents/tests/collab-saver-handoff.test.js b/src/documents/tests/collab-saver-handoff.test.js index 2934c3354..8ad9984f6 100644 --- a/src/documents/tests/collab-saver-handoff.test.js +++ b/src/documents/tests/collab-saver-handoff.test.js @@ -77,4 +77,20 @@ describe('the owner tab that saves a shared document', () => { expect(await P.__files.get(PATH).blob.text()).toBe('Shared plan!'); second.live.destroy(); }); + + it('keeps saving when someone without the owner\'s role claims to save the file', async () => { + const hub = new MemoryHub(Y.encodeStateAsUpdate(createSharedDoc(editorState({ session: { body: new TextDocument([paragraph('Shared')]), storyEdits: {} } })))); + const owner = await ownerTab(hub, 'b'); + await settle(); + expect(owner.doc.writes).toBe(true); + // An editor's (modified) client says it saves, from a session that sorts before the owner's; the service stamps + // each presence with its sender's role. + const guest = hub.connect('editor'); + await guest.join({ update() {}, presence() {}, role() {} }); + guest.presence({ session: '0', role: 'editor', saves: true, at: Date.now() }); + await settle(); + expect(owner.live.saves).toBe(true); + expect(owner.doc.writes).toBe(true); + owner.live.destroy(); + }); }); From 3442122c005a071e7f3a685fb6943edb994269a0 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:58:04 -0700 Subject: [PATCH 031/186] Documents: share pictures added while one person edits a shared document alone A shared document with content its shared text cannot hold (an empty form field, say) is edited by one person at a time, whose copy reaches the others as a snapshot. Pictures added then were named in the snapshot but never put in the shared pictures: the others saw an empty frame, and when the editor was a guest the owner's tab could no longer save the file ("An image asset is missing"). They now go into the shared pictures, under the editor's own id, as in ordinary sharing. --- src/documents/src/collab/binding.js | 8 ++++---- src/documents/tests/collab-binding.test.js | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/documents/src/collab/binding.js b/src/documents/src/collab/binding.js index a658bc3b2..ed48aeb4b 100644 --- a/src/documents/src/collab/binding.js +++ b/src/documents/src/collab/binding.js @@ -143,13 +143,13 @@ export class CollabBinding extends EventTarget { if (engine.composition) { this.waitForComposition(); return; } if (this.role === 'viewer' || this.forcedViewOnly()) { this.drainQueue(); return; } try { + // New pictures go into the shared asset map whichever way the document is shared: a single editor's snapshot + // names them too, and without their bytes the others could not show them nor the owner's tab save the file. + this.uploadAssets(); const state = editorState(engine); const reason = unsupportedReason(state); if (this.mode === 'single' || reason) this.writeSnapshot(state, reason); - else { - this.uploadAssets(); - writeState(this.ydoc, state, this, this.bodyCache); - } + else writeState(this.ydoc, state, this, this.bodyCache); } catch (error) { this.bodyCache.body = null; this.onError(error); } this.refreshHistory(); this.drainQueue(); diff --git a/src/documents/tests/collab-binding.test.js b/src/documents/tests/collab-binding.test.js index cc559317d..8d3fc139d 100644 --- a/src/documents/tests/collab-binding.test.js +++ b/src/documents/tests/collab-binding.test.js @@ -199,6 +199,21 @@ describe('co-editing binding', () => { expect(bob.engine.viewOnly).toBe(false); }); + it('sends a picture added in single-editor mode to the others with its bytes', async () => { + const { people: [ann, bob] } = await room('form'); + ann.engine.execute(m => { m.paragraphs[0].runs.push({ ...m.paragraphs[0].runs[0], text: '', contentControl: { id: 7, placeholder: true } }); m.didChange(); }); + await settle(); + expect(ann.binding.mode).toBe('single'); + ann.engine.execute(m => { m.assets.logo = { bytes: new Uint8Array([137, 80, 78, 71]), mime: 'image/png' }; m.select(0); m.insertImage('logo', { width: 100, height: 100 }); }); + await settle(); + const run = bob.engine.model.paragraphs[0].runs.find(r => r.image); + expect(run).toBeTruthy(); + // Without the picture's bytes the others cannot show it, and the owner's tab cannot save the file. + expect(bob.engine.session.body.assets[run.image.assetId]?.bytes).toEqual(new Uint8Array([137, 80, 78, 71])); + // Its id is this person's own, so it cannot stand for another picture someone else adds. + expect(run.image.assetId).toMatch(/@/); + }); + it('gives people stable, readable colors and parses links', () => { expect(colorFor('alice')).toBe(colorFor('alice')); expect(PERSON_COLORS).toContain(colorFor('bob')); From 69670de7c62ae59de774557417a48963a54ce36a Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:01:15 -0700 Subject: [PATCH 032/186] Documents: undo and redo while editing a shared form alone A shared document with content its shared text cannot hold (an empty form field) is edited by one person at a time. Undo and Redo followed the shared text's history, which such editing never adds to, and every change emptied the editor's own: the buttons stayed disabled and Ctrl+Z did nothing. The person editing alone now undoes and redoes their own changes, and the others get the result. --- src/documents/src/collab/binding.js | 10 ++++++++++ src/documents/tests/collab-binding.test.js | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/documents/src/collab/binding.js b/src/documents/src/collab/binding.js index ed48aeb4b..4a054e06b 100644 --- a/src/documents/src/collab/binding.js +++ b/src/documents/src/collab/binding.js @@ -88,6 +88,7 @@ export class CollabBinding extends EventTarget { if (!this.applying) this.scheduleSync(); return result; }; + this.originalTravel = engine.travelHistory?.bind(engine); engine.travelHistory = direction => this.travel(direction); // Undo and Redo of shared documents take back this person's own changes. engine.undo = () => this.travel('undo'); @@ -379,6 +380,9 @@ export class CollabBinding extends EventTarget { const engine = this.engine; if (engine.readOnly || engine.viewOnly || engine.composition) return; this.flush(); + // One person edits alone: the shared text does not change, and their own history is the document's. Its result + // reaches the others as the next snapshot. + if (this.mode === 'single' && this.holdsLock() && this.originalTravel) { this.originalTravel(direction); this.scheduleSync(); return; } const manager = this.undoManager; const stack = direction === 'undo' ? manager.undoStack : manager.redoStack; const item = stack.at(-1); @@ -392,6 +396,12 @@ export class CollabBinding extends EventTarget { /** The editor's Undo and Redo buttons read the session's history: mirror this person's own stacks there. */ refreshHistory() { const session = this.engine.session; + // Editing alone, this person's own history stands, without the shared changes' places in it. + if (this.mode === 'single') { + session.history = session.history.filter(entry => entry !== HISTORY_ENTRY); + session.future = session.future.filter(entry => entry !== HISTORY_ENTRY); + return; + } session.history = this.undoManager.undoStack.map(() => HISTORY_ENTRY); session.future = this.undoManager.redoStack.map(() => HISTORY_ENTRY); } diff --git a/src/documents/tests/collab-binding.test.js b/src/documents/tests/collab-binding.test.js index 8d3fc139d..cfe3c2703 100644 --- a/src/documents/tests/collab-binding.test.js +++ b/src/documents/tests/collab-binding.test.js @@ -214,6 +214,27 @@ describe('co-editing binding', () => { expect(run.image.assetId).toMatch(/@/); }); + it('undoes and redoes the single editor\'s own changes, and shares the result', async () => { + const { people: [ann, bob] } = await room('form'); + // An empty form field, which the shared text cannot hold, as an opened form brings one. + ann.engine.execute(m => { m.paragraphs[0].runs = [...m.paragraphs[0].runs, { ...m.paragraphs[0].runs[0], text: '', contentControl: { id: 7, persistent: true } }]; m.didChange(); }); + await settle(); + expect(ann.binding.mode).toBe('single'); + ann.engine.execute(m => { m.select(0); m.replace('Typed '); }); + await settle(); + expect(bob.engine.model.text).toBe('Typed form'); + expect(ann.engine.session.history.length).toBeGreaterThan(0); + ann.engine.undo(); + await settle(); + expect(ann.engine.model.text).toBe('form'); + expect(bob.engine.model.text).toBe('form'); + expect([ann.engine.session.future.length, ann.binding.mode, ann.binding.holdsLock()]).toEqual([1, 'single', true]); + ann.engine.redo(); + await settle(); + expect(ann.engine.model.text).toBe('Typed form'); + expect(bob.engine.model.text).toBe('Typed form'); + }); + it('gives people stable, readable colors and parses links', () => { expect(colorFor('alice')).toBe(colorFor('alice')); expect(PERSON_COLORS).toContain(colorFor('bob')); From 223ea2dad906ac77f235b01d8c830cdf8dd682d8 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:03:21 -0700 Subject: [PATCH 033/186] Documents: keep a reopened shared document read-only when its room cannot be reached Reopening a document they had shared, the owner waits read-only until its room is joined, since the room's state then replaces the editor's. A room that could not be reached (offline, a server error) was taken for one whose sharing had ended: the notice went, the document became editable and unshared without a word, and the next reopen that reached the room replaced those edits (with no version kept when only formatting changed). The document now stays read-only, says it could not connect, and offers Try Again; only a room that is gone opens it as the owner's own. --- src/documents/src/collab/live.js | 10 ++++--- src/documents/src/views/collab.js | 20 +++++++++++--- src/documents/tests/collab-reconnect.test.js | 27 +++++++++++++++++++ .../tests/collab-reopen-hold.test.js | 27 ++++++++++++++++++- 4 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 src/documents/tests/collab-reconnect.test.js diff --git a/src/documents/src/collab/live.js b/src/documents/src/collab/live.js index 306682aa3..83834b8a4 100644 --- a/src/documents/src/collab/live.js +++ b/src/documents/src/collab/live.js @@ -294,7 +294,11 @@ export async function shareDocument({ engine, doc, user, name, baseBytes, local, return { client, link, reason }; } -/** Rejoin the room of a document this account shared before (owner reopening it); `saved`: the room, when already looked up. */ +/** + * Rejoin the room of a document this account shared before (owner reopening it); `saved`: the room, when already looked + * up. Null when there is no room to join (never shared, or sharing was stopped); a room that could not be reached just + * now (offline, a server error) throws, since it is still the document's and its state replaces the file's once joined. + */ export async function reconnect(path, user, saved) { if (saved === undefined) saved = await rememberedRoom(path); if (!saved?.server || !saved?.room) return null; @@ -305,8 +309,8 @@ export async function reconnect(path, user, saved) { return { client, link: saved }; } catch (error) { // Sharing was stopped (or the room is gone): forget it. - if (error.status === 410 || error.status === 404) await rememberRoom(path, null); - return null; + if (error.status === 410 || error.status === 404) { await rememberRoom(path, null); return null; } + throw error; } } diff --git a/src/documents/src/views/collab.js b/src/documents/src/views/collab.js index 54acd8daa..f6fac6074 100644 --- a/src/documents/src/views/collab.js +++ b/src/documents/src/views/collab.js @@ -175,10 +175,24 @@ export function createCollaboration({ editor, pkg, doc, local, error, onChange = const saved = await remembered; if (!saved?.server || !saved?.room || destroyed) return; const release = holdEditing(editor()); - try { + let found; + for (;;) { setBanner('Connecting to the shared document… You can edit it once it has opened.'); - const { reconnect } = await loadLive(); - const found = await reconnect(doc.path, user(), saved); + try { + const { reconnect } = await loadLive(); + found = await reconnect(doc.path, user(), saved); + break; + } catch (e) { + if (destroyed) return; + console.warn('[documents] could not reach the shared document', e); + // The room is still the document's: changes made here now would not reach the others, and its state + // replaces the editor's once it is joined. The document reads only until it has been. + await new Promise(resolve => setBanner('Could not connect to the shared document, so it is open read-only. Check your connection and try again.', + [el('button.btn.btn-sm.btn-primary', { type: 'button', onclick: () => resolve() }, 'Try Again')])); + if (destroyed) return; + } + } + try { if (!live) setBanner(''); release(); if (found) await begin(found.client, found.link, { reopened: true }); diff --git a/src/documents/tests/collab-reconnect.test.js b/src/documents/tests/collab-reconnect.test.js new file mode 100644 index 000000000..2c051d38d --- /dev/null +++ b/src/documents/tests/collab-reconnect.test.js @@ -0,0 +1,27 @@ +import { it, expect, vi, beforeEach } from 'vitest'; + +let failure = null; +const forgotten = []; +vi.mock('../src/collab/api.js', async (importOriginal) => ({ ...await importOriginal(), call: async () => { throw failure; } })); +vi.mock('../src/collab/room-memory.js', async (importOriginal) => ({ ...await importOriginal(), rememberRoom: async (path, link) => { forgotten.push([path, link]); } })); +const { reconnect } = await import('../src/collab/live.js'); + +const saved = { server: 'https://documents-abc.puter.work', room: 'room1' }; +const user = { id: 'u0', name: 'Ada' }; +beforeEach(() => { forgotten.length = 0; }); + +it('forgets a room whose sharing was stopped, and opens the document as the owner\'s own', async () => { + for (const status of [404, 410]) { + failure = Object.assign(new Error('This shared document is no longer available.'), { status }); + expect(await reconnect('Documents/Plan.docx', user, saved)).toBeNull(); + } + expect(forgotten).toEqual([['Documents/Plan.docx', null], ['Documents/Plan.docx', null]]); +}); + +it('says so when the room cannot be reached just now, and keeps it', async () => { + for (const status of [0, 500, 503]) { + failure = Object.assign(new Error('Failed to fetch'), { status }); + await expect(reconnect('Documents/Plan.docx', user, saved)).rejects.toThrow('Failed to fetch'); + } + expect(forgotten).toEqual([]); +}); diff --git a/src/documents/tests/collab-reopen-hold.test.js b/src/documents/tests/collab-reopen-hold.test.js index 7fc36f4ae..7c4a552b0 100644 --- a/src/documents/tests/collab-reopen-hold.test.js +++ b/src/documents/tests/collab-reopen-hold.test.js @@ -9,13 +9,15 @@ import { CollabClient } from '../src/collab/client.js'; import { MemoryHub } from '../src/collab/transports.js'; const hub = new MemoryHub(Y.encodeStateAsUpdate(createSharedDoc(editorState({ session: { body: new TextDocument([paragraph('Shared')]), storyEdits: {} } })))); -let release = null, remembered = null; +let release = null, remembered = null, failures = 0; vi.mock('../src/collab/room-memory.js', async (importOriginal) => ({ ...await importOriginal(), rememberedRoom: async () => remembered })); vi.mock('../src/collab/live.js', async (importOriginal) => ({ ...await importOriginal(), // Joining the owner's room takes a few round trips; the test decides when they end. reconnect: async () => { await new Promise(resolve => { release = resolve; }); + // The service could not be reached (a network failure, a server error): the room is still there. + if (failures > 0) { failures--; throw Object.assign(new Error('Failed to fetch'), { status: 0 }); } const client = new CollabClient({ transport: hub.connect('owner'), user: { id: 'u0', name: 'Ada' }, room: 'room1', sendDelay: 0 }); await client.connect(); return { client, link: { server: 'https://documents-abc.puter.work', room: 'room1' } }; @@ -84,3 +86,26 @@ it('keeps Viewing chosen while the room was joined', async () => { expect(engine.viewOnly).toBe(true); collab.destroy(); }); + +it('stays read-only with a way to try again when the room of a reopened shared document cannot be reached', async () => { + remembered = { server: 'https://documents-abc.puter.work', room: 'room1' }; + release = null; failures = 1; + const engine = new FakeEngine('Shared'); + const collab = open(engine); + collab.attach(); + await vi.waitFor(() => expect(release).toBeTypeOf('function')); + const first = release; + release = null; + first(); + // Edits made now would neither reach the others nor survive: the room's state replaces the editor's once joined. + await vi.waitFor(() => expect(collab.banner.textContent).toContain('Could not connect to the shared document')); + expect(engine.viewOnly).toBe(true); + expect(collab.live).toBeNull(); + const retry = [...collab.banner.querySelectorAll('button')].find(b => b.textContent === 'Try Again'); + retry.click(); + await vi.waitFor(() => expect(release).toBeTypeOf('function')); + release(); + await vi.waitFor(() => expect(collab.live).not.toBeNull()); + expect(engine.viewOnly).toBe(false); + collab.destroy(); +}); From 4a9050687600e99c1a4192d211b3e540bb6a309e Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:04:12 -0700 Subject: [PATCH 034/186] Documents: keep bold and italics a web page's text inherits when its style says inherit A font-weight or font-style of inherit or unset (which a reset style sheet's "font: inherit" gives every span) was read as normal, so bold or italic text with a span inside lost its emphasis there when a web page was opened or pasted. Such an element now takes its parent's weight and style, as a browser shows it. --- src/documents/src/engine/clipboard-fragment.js | 9 +++++++-- src/documents/tests/clipboard-fragment.test.js | 8 ++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/documents/src/engine/clipboard-fragment.js b/src/documents/src/engine/clipboard-fragment.js index aba5e848a..d66fac84f 100644 --- a/src/documents/src/engine/clipboard-fragment.js +++ b/src/documents/src/engine/clipboard-fragment.js @@ -175,8 +175,13 @@ function inherited(el, format) { if (tag === 'S' || tag === 'STRIKE' || tag === 'DEL') next.strikethrough = 'single'; if (tag === 'SUP') next.verticalAlign = 'superscript'; if (tag === 'SUB') next.verticalAlign = 'subscript'; - if (s.fontWeight) next.bold = s.fontWeight === 'bold' || s.fontWeight === 'bolder' || Number(s.fontWeight) >= 600; - if (s.fontStyle) next.italic = s.fontStyle === 'italic' || s.fontStyle === 'oblique'; + // A weight or style the element inherits (inherit, unset: both inherited properties) is its parent's, as a reset + // style sheet's "font: inherit" makes a span inside bold text say. + const own = value => value && !/^(inherit|unset)$/i.test(value); + if (own(s.fontWeight)) next.bold = s.fontWeight === 'bold' || s.fontWeight === 'bolder' || Number(s.fontWeight) >= 600; + else if (s.fontWeight) next.bold = format.bold; + if (own(s.fontStyle)) next.italic = s.fontStyle === 'italic' || s.fontStyle === 'oblique'; + else if (s.fontStyle) next.italic = format.italic; const decoration = `${s.textDecorationLine || ''} ${s.textDecoration || ''}`; if (/underline/.test(decoration)) next.underlineStyle = 'single'; if (/line-through/.test(decoration)) next.strikethrough = 'single'; diff --git a/src/documents/tests/clipboard-fragment.test.js b/src/documents/tests/clipboard-fragment.test.js index f7b06ecf2..f0dbe6f68 100644 --- a/src/documents/tests/clipboard-fragment.test.js +++ b/src/documents/tests/clipboard-fragment.test.js @@ -106,3 +106,11 @@ it('keeps a table row on one line when its cells hold paragraphs, as Word, Libre expect(texts('

    Item

    Two
    lines

    After

    ')).toEqual(['Item\tTwo lines', 'After']); expect(texts('

    Head

    Plain
    ')).toEqual(['Head\tPlain']); }); + +it('keeps the emphasis a span inherits when its style says inherit, as a reset style sheet makes it say', () => { + const runs = html => htmlFragment(html)?.paragraphs.flatMap(p => p.runs.map(r => [r.text, !!r.format.bold, !!r.format.italic])); + expect(runs('

    Bold word Slanted word

    ')) + .toEqual([['Bold word', true, false], [' ', false, false], ['Slanted word', false, true]]); + // A weight or style of its own still counts. + expect(runs('

    Bold plain

    ')).toEqual([['Bold ', true, false], ['plain', false, false]]); +}); From a491b1b465d0cc63123547bc145dc4b31ea761cc Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:04:45 -0700 Subject: [PATCH 035/186] Documents: link a Google Docs web page's links to their addresses, not Google's redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google Docs' Download > Web page sends every link through https://www.google.com/url?q=
    &usg=…, so a document opened from it linked to Google's redirect, which records each click and stops working after a while. Opening such a page now links to the address itself. --- src/documents/src/lib/importers.js | 8 ++++++++ src/documents/tests/importers.test.js | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/src/documents/src/lib/importers.js b/src/documents/src/lib/importers.js index d6d581459..9e08f9147 100644 --- a/src/documents/src/lib/importers.js +++ b/src/documents/src/lib/importers.js @@ -71,6 +71,14 @@ export function htmlBody(html, { preserveFallback = false } = {}) { } } } + // A web page Google Docs exports sends every link through Google's redirect (google.com/url?q=
    &usg=…): the + // document links to the address itself, as the text it was written in did. + for (const a of parsed.querySelectorAll('a[href^="https://www.google.com/url?"]')) { + try { + const target = new URL(a.getAttribute('href')).searchParams.get('q'); + if (target && /^(https?:|mailto:|tel:)/i.test(target)) a.setAttribute('href', target); + } catch { /* not an address: left as it is */ } + } // Tables the converter would refuse (a row-spanning cell, a table in a cell) are rewritten into ones it takes. if (parsed.body?.querySelector('table')) normalizeTables(parsed.body); // HTML consumes one optional newline immediately after a PRE start tag. diff --git a/src/documents/tests/importers.test.js b/src/documents/tests/importers.test.js index e58fc4961..da898ac2d 100644 --- a/src/documents/tests/importers.test.js +++ b/src/documents/tests/importers.test.js @@ -35,6 +35,13 @@ describe('htmlBody', () => { expect(htmlBody(html)).toBe('

    Hi

    Text bold

    '); expect(htmlBody('

    fragment

    ')).toBe('

    fragment

    '); }); + it('links a Google Docs web page\'s links to where they go, not through Google\'s redirect', () => { + const page = '

    report and mail

    '; + expect(htmlBody(page)).toBe('

    report and mail

    '); + // Other addresses on google.com, and redirects to what a link may not open, stay as they are. + expect(htmlBody('

    s x

    ')) + .toBe('

    s x

    '); + }); it('keeps the text of a page whose body is one form', () => { expect(htmlBody('

    Title

    Body

    ')).toBe('

    Title

    Body

    '); }); From 5cde8dcc3531c2a9ab7de7b95a0ce5b1a7b1d269 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:28:25 -0700 Subject: [PATCH 036/186] Documents: Formatting changes stay tracked while tracking is locked With tracking locked, Tracking Options could still turn Track formatting off (or the document already had it off), and bold, alignment and other formatting changes were then made untracked; the lock only checked the text. Locked tracking now records formatting changes whatever that setting says, and Tracking Options shows the setting on and unavailable while locked. --- src/documents/src/views/restrict-editing.js | 5 +- src/documents/src/views/review-tools.js | 9 ++-- src/documents/src/views/tracking-options.js | 5 +- .../browser/locked-tracking-options.spec.js | 46 +++++++++++++++++++ 4 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 src/documents/tests/browser/locked-tracking-options.spec.js diff --git a/src/documents/src/views/restrict-editing.js b/src/documents/src/views/restrict-editing.js index 5d17bc0d5..00421871b 100644 --- a/src/documents/src/views/restrict-editing.js +++ b/src/documents/src/views/restrict-editing.js @@ -73,7 +73,10 @@ export function createRestrictEditing({ editor, pkg, error, author, onChange = ( const engine = editor(); if (!engine || !api()) return; const active = readOnlyView ? { edit: 'readOnly', enforcement: true } : protection(); engine.session.guard = api().protectionGuard(active && { ...active, lockedStyles: settings().lockedStyles || [] }, user(), { contentControlRanges: api().contentControlRanges }); - if (active?.enforcement && active.edit === 'trackedChanges' && !engine.session.body.trackChanges) { engine.session.body.trackChanges = true; engine.session.revision++; } + const locked = !!active?.enforcement && active.edit === 'trackedChanges'; + if (locked && !engine.session.body.trackChanges) { engine.session.body.trackChanges = true; engine.session.revision++; } + // Locked tracking records formatting changes too, whatever the document's Track formatting setting. + engine.session.body.trackFormatting = locked || !settings().doNotTrackFormatting; onChange(); } diff --git a/src/documents/src/views/review-tools.js b/src/documents/src/views/review-tools.js index 9a07b4dde..59627b8d0 100644 --- a/src/documents/src/views/review-tools.js +++ b/src/documents/src/views/review-tools.js @@ -64,11 +64,14 @@ export function createReviewTools({ editor, pkg, error, author, review, proofing function trackingOptions() { const engine = editor(); if (!engine) return; const settings = pkg()?.reviewSettings || {}; - openTrackingOptions({ authors: () => people(), doc: { trackMoves: !settings.doNotTrackMoves, trackFormatting: !settings.doNotTrackFormatting }, + // While tracking is locked, formatting changes are tracked: the setting is shown on and cannot be changed. + const locked = restrict.trackingLocked; + openTrackingOptions({ authors: () => people(), doc: { trackMoves: !settings.doNotTrackMoves, trackFormatting: locked || !settings.doNotTrackFormatting, formattingLocked: locked }, apply: (options, doc) => { Object.assign(revisionView.options, options); rememberTrackingOptions(); - if (!pkg()?.readOnly && (doc.trackMoves !== !settings.doNotTrackMoves || doc.trackFormatting !== !settings.doNotTrackFormatting)) setSettings({ doNotTrackMoves: !doc.trackMoves, doNotTrackFormatting: !doc.trackFormatting }); + const trackFormatting = locked ? !settings.doNotTrackFormatting : doc.trackFormatting; + if (!pkg()?.readOnly && (doc.trackMoves !== !settings.doNotTrackMoves || trackFormatting !== !settings.doNotTrackFormatting)) setSettings({ doNotTrackMoves: !doc.trackMoves, doNotTrackFormatting: !trackFormatting }); applyTrackingSettings(); review.commands.setPaneOrientation(options.paneOrientation); editor()?.relayout(); review.refresh(true); onChange(); }, onClose: () => queueMicrotask(() => editor()?.focus()) }); } @@ -76,7 +79,7 @@ export function createReviewTools({ editor, pkg, error, author, review, proofing const engine = editor(); if (!engine) return; const settings = pkg()?.reviewSettings || {}; engine.session.body.trackMoves = !settings.doNotTrackMoves; - engine.session.body.trackFormatting = !settings.doNotTrackFormatting; + engine.session.body.trackFormatting = restrict.trackingLocked || !settings.doNotTrackFormatting; revisionView.options.trackMoves = !settings.doNotTrackMoves; } async function lockTracking() { diff --git a/src/documents/src/views/tracking-options.js b/src/documents/src/views/tracking-options.js index af65ad8ab..79d5b5d5e 100644 --- a/src/documents/src/views/tracking-options.js +++ b/src/documents/src/views/tracking-options.js @@ -53,7 +53,7 @@ export function openTrackingOptions({ authors = () => [], doc = {}, apply, onClo el('span', { style: o.formatMark === 'none' ? '' : style(o.formatMark, o.formatColor) }, 'reformatted'), ' text.')); } const field = (label, control) => el('label.field.rv-field', {}, el('span.field-label', {}, label), control); - const check = (label, checked, onchange) => { const input = el('input', { type: 'checkbox', checked, onchange: () => { onchange(input.checked); renderPreview(); } }); return el('label.rv-check', {}, input, label); }; + const check = (label, checked, onchange, disabled = false) => { const input = el('input', { type: 'checkbox', checked, disabled, onchange: () => { onchange(input.checked); renderPreview(); } }); return el('label.rv-check', {}, input, label); }; const set = key => value => { o[key] = value; renderPreview(); }; const authorRows = authors().map(name => field(name, select(`${name} color`, o.authorColors?.[name] || 'author', [['author', 'Automatic'], ...COLORS.slice(1)], value => { o.authorColors = { ...o.authorColors }; if (value === 'author') delete o.authorColors[name]; else o.authorColors[name] = value; renderPreview(); }))); const content = el('form.ed-dialog-body.rv-tracking-options', { onsubmit: e => { e.preventDefault(); modal.close(); apply(o, d); } }, @@ -68,7 +68,8 @@ export function openTrackingOptions({ authors = () => [], doc = {}, apply, onClo field('Moved from', select('Moved from mark', o.moveFromMark, markOptions(DELETE_MARKS.filter(m => m !== 'hidden')), set('moveFromMark'))), field('Color', select('Move color', o.moveColor || MOVE_COLOR, COLORS.slice(1), set('moveColor'))), field('Moved to', select('Moved to mark', o.moveToMark, markOptions(INSERT_MARKS), set('moveToMark'))))), el('fieldset.rv-fieldset', {}, el('legend', {}, 'Formatting'), - check('Track formatting', d.trackFormatting, value => { d.trackFormatting = value; }), + check('Track formatting', d.trackFormatting, value => { d.trackFormatting = value; }, !!doc.formattingLocked), + doc.formattingLocked ? hint('Tracking is locked, so formatting changes are tracked.') : null, el('div.rv-grid', {}, field('Formatting', select('Formatting mark', o.formatMark, markOptions(FORMAT_MARKS), set('formatMark'))), field('Color', select('Formatting color', o.formatColor, COLORS, set('formatColor'))))), el('fieldset.rv-fieldset', {}, el('legend', {}, 'Balloons and pane'), field('Balloons', select('Balloons', o.balloons, BALLOONS, set('balloons'))), diff --git a/src/documents/tests/browser/locked-tracking-options.spec.js b/src/documents/tests/browser/locked-tracking-options.spec.js new file mode 100644 index 000000000..d8816eec4 --- /dev/null +++ b/src/documents/tests/browser/locked-tracking-options.spec.js @@ -0,0 +1,46 @@ +import { test, expect } from '@playwright/test'; + +const engine = 'window.__docs.current.view.engine'; +const review = page => page.locator('#ribbon-panel-review'); +const revisions = page => page.evaluate(engine => { const m = eval(engine).model; return m.revisions.map(r => [r.type, m.text.slice(r.start, r.end)]); }, engine); + +async function trackingOptions(page) { + await review(page).locator('[aria-label="Tracking Options"]').evaluate(b => b.click()); + return page.getByRole('dialog', { name: 'Tracking Options' }); +} +async function lockTracking(page) { + await review(page).locator('[aria-label="Track Changes options"]').evaluate(b => b.click()); + await page.getByRole('menuitem', { name: 'Lock Tracking…' }).click(); + const dialog = page.getByRole('dialog', { name: 'Lock Tracking' }); + await dialog.getByLabel('Password', { exact: true }).fill('lock'); + await dialog.getByLabel('Reenter password').fill('lock'); + await dialog.getByRole('button', { name: 'OK', exact: true }).click(); + await expect(review(page).getByRole('button', { name: 'Track Changes', exact: true })).toHaveAttribute('aria-pressed', 'true'); +} + +test('while tracking is locked, formatting changes are tracked even where the document turned formatting tracking off', async ({ page }) => { + const errors = []; page.on('pageerror', e => errors.push(e.message)); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/mock.html#/new'); + await expect(page.getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + const input = page.getByRole('textbox', { name: 'Document text' }); + await input.fill('Alpha beta gamma.'); + await page.getByRole('tab', { name: 'Review' }).click(); + // The author turns formatting tracking off, then locks tracking. + let dialog = await trackingOptions(page); + await dialog.getByRole('checkbox', { name: 'Track formatting' }).uncheck(); + await dialog.getByRole('button', { name: 'OK', exact: true }).click(); + await lockTracking(page); + // Bold, made while tracking is locked, is a tracked change. + await input.evaluate(el => { el.focus(); el.setSelectionRange(6, 10); el.dispatchEvent(new Event('select', { bubbles: true })); }); + await input.press('ControlOrMeta+b'); + await expect.poll(() => page.evaluate(engine => eval(engine).model.paragraphs[0].runs.some(r => r.bold), engine)).toBe(true); + expect(await revisions(page)).toEqual([['format', 'beta']]); + // Tracking Options cannot turn formatting tracking off while tracking is locked. + dialog = await trackingOptions(page); + const formatting = dialog.getByRole('checkbox', { name: 'Track formatting' }); + await expect(formatting).toBeChecked(); + await expect(formatting).toBeDisabled(); + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click(); + expect(errors).toEqual([]); +}); From 79c88d353e816ef41b72ccf048fb52b3bc8d9642 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:34:05 -0700 Subject: [PATCH 037/186] Documents: Open files whose main document part is named document2.xml or capitalized differently OPC finds the main part through the package relationship, not its name. Files from the Open XML SDK can name it word/document2.xml, and some producers write ./word/document.xml or other capitals; all were refused. Such a main part is now renamed to word/document.xml on opening, with its relationships, content type entry and package relationship. --- src/documents/docs/conformance.md | 1 + src/documents/src/engine/docx-main-part.js | 69 ++++++++++++++++++++++ src/documents/src/engine/docx.js | 2 + src/documents/tests/docx-main-part.test.js | 68 +++++++++++++++++++++ 4 files changed, 140 insertions(+) create mode 100644 src/documents/src/engine/docx-main-part.js create mode 100644 src/documents/tests/docx-main-part.test.js diff --git a/src/documents/docs/conformance.md b/src/documents/docs/conformance.md index 15e257bf3..6530b19f9 100644 --- a/src/documents/docs/conformance.md +++ b/src/documents/docs/conformance.md @@ -24393,3 +24393,4 @@ reason in the document itself. Each row has a unit regression that fails without | Content type entries for created parts | ECMA-376 Part 2 §10.1.2 content types stream (`Override`, at most one per part name), §9.1.1 part names (compared case-insensitively) | Creating core properties, settings, numbering, styles, a header or footer, footnotes, endnotes or a picture part appended an `Override` without looking for one already naming that part. A package that kept an entry for a part it no longer contains (as some tools leave behind) was saved with two entries for the same part name, which is an invalid package. Every creator now sets the part's entry through one helper that reuses (and corrects the content type of) an existing entry, matching part names without regard to case, and removes repeats. Unit regression `tests/content-type-overrides.test.js`: a package with leftover entries for all eight kinds, each part created, exactly one entry per name with the needed type, and the result reopens; its XML parts pass `check:conformance` (the checker does not validate the content types stream). Comments, bibliography and mail-merge parts already checked for an entry and are unchanged. | | Tracked field code | §17.13.5.18 `ins`, §17.13.5.14 `del` (CT_RunTrackChange: runs, no `fldSimple`), §17.16.18 `fldChar`, §17.16.23 `instrText`, §17.16 `delInstrText` (deleted field code), §17.3.3.8 `delText` | A field inserted or deleted with Track Changes on has its code (begin, instruction, separator and end delimiters) inside the `w:ins` or `w:del`, and a deleted field's instruction is `w:delInstrText`. `w:delInstrText` made the whole document open read-only ("visible content inside a body field instruction"). A tracked field opened editable lost its tracking on save: the delimiters were written outside the change, so rejecting an inserted field (or accepting a deleted one), here or in Word, left an empty field behind, and accepting or rejecting here did the same. A field whose begin, separator and end delimiters sit in one change now keeps that change; saving writes its delimiters (and `w:delInstrText` for a deletion) inside the change, merged with its result; accepting or rejecting the change, at the caret, over a selection or for all changes, removes or keeps the whole field, and a deletion of another reviewer's insertion resolves as its text does. A field only partly inside a change keeps the earlier behaviour. Unit regression `tests/tracked-field-codes.test.js` (inserted, deleted and deleted-inserted fields: open, byte-identical save, delimiters inside the change after an edit, reopen, accept/reject all, at the caret and by reviewer, undo); edited exports pass `check:conformance`. Gaps: tracked fields in headers, footers and text boxes are not handled; a tracked field with an empty result (an index entry) is resolved only by resolving a stretch or all changes, and is not listed as a change of its own. | | Fields inserted or deleted while tracking | §17.13.5.18 `ins`, §17.13.5.14 `del`, §17.16.18 `fldChar` | With Track Changes on, inserting a field (Date and Time, Field, page numbers placed in the body, captions and cross-references) marked only its result inserted, and deleting a selection that took a whole field marked only its result deleted. Saved, the field's delimiters sat outside the change, so rejecting the insertion (or accepting the deletion) in Word, or here after reopening, left an empty field. The field's code is now part of the change: a field inserted while tracking (or added by a command whose insertion is then tracked) records the insertion; a deletion that takes a whole field, by the same rule an untracked deletion removes it (the selection reaches past at least one end), records the deletion, nested in another reviewer's insertion where there is one; the author's own inserted field is removed outright. Selecting exactly the result and deleting it deletes only the result, as it does untracked. Unit regression `tests/tracked-field-codes.test.js` (tracked insert, tracked command insertion, whole-field deletion saved, reopened and accepted, result-only deletion, own and other reviewers' fields). | +| Main document part names | ECMA-376 Part 2 §8.3 package relationships, §9.1.1.1 part names (compared without regard to ASCII case), §10.1.2 content types; Part 1 §11.3.10 main document part | The main document part is the target of the package's `…/officeDocument` relationship, whatever its name. Packages whose main part is `word/document2.xml` (written by the Open XML SDK), whose relationship targets `./word/document.xml`, or whose relationship or `Override` names the part with other capitals (`/word/Document.xml`) were refused outright ("This file is not a supported DOCX document", "Unsupported DOCX document location", "Only standard .docx files are supported"). A main part stored under another name directly in `word/` is now renamed to `word/document.xml` on opening, with its relationships part, and the package relationship and content type entry are pointed at the new name; relative targets keep resolving because the folder does not change. A package whose main part is already `word/document.xml` is not rewritten. A main part in another folder, or one that competes with an existing `word/document.xml`, is still refused. Unit regression `tests/docx-main-part.test.js` (four variants: editable open, styles resolve, saved names, reopen; untouched package parts byte for byte; refusals). | diff --git a/src/documents/src/engine/docx-main-part.js b/src/documents/src/engine/docx-main-part.js new file mode 100644 index 000000000..8e2bdd81f --- /dev/null +++ b/src/documents/src/engine/docx-main-part.js @@ -0,0 +1,69 @@ +import { strToU8 } from 'fflate'; +import { parseXml } from './docx-xml.js'; + +/** + * The editor reads and writes the main document part at word/document.xml. OPC locates that part + * through the package relationship of type …/officeDocument (ECMA-376 Part 2, §8.3 and Part 1, + * §11.3.10), not by its name, and part names compare without regard to ASCII case (Part 2, §9.1.1.1). + * Packages written by the Open XML SDK can name it word/document2.xml, and other producers write + * the target as ./word/document.xml or with different capitals. + * + * A main part stored elsewhere in the word folder is renamed to word/document.xml together with its + * relationships part, and the package relationship and content type entry are pointed at the new + * name. Relationship targets of the part are relative to its folder, which stays the same, so they + * resolve exactly as before. Packages that already name it word/document.xml are left untouched. + */ +const MAIN_RELATIONSHIP = /\/officeDocument$/; +const MAIN = 'word/document.xml'; + +const findPart = (parts, name) => Object.keys(parts).find(key => key.toLowerCase() === name.toLowerCase()); + +function mainTarget(rels) { + const links = [...rels.documentElement.children].filter(n => n.localName === 'Relationship' && MAIN_RELATIONSHIP.test(n.getAttribute('Type') || '') && n.getAttribute('TargetMode') !== 'External'); + if (links.length !== 1) return null; + const target = links[0].getAttribute('Target'); + if (!target || /[\\?#:]/.test(target)) return null; + // The package relationships part belongs to the package root, so targets resolve from there. + const path = []; + for (const segment of target.replace(/^\//, '').split('/')) { + if (segment === '.') continue; + if (!segment || segment === '..') return null; + try { path.push(decodeURIComponent(segment)); } catch { return null; } + } + return { link: links[0], path: path.join('/') }; +} + +/** Rename the main document part to word/document.xml when it is stored under another name in word/. */ +export function canonicalMainPart(parts) { + if (!parts['_rels/.rels'] || !parts['[Content_Types].xml']) return parts; + let rels; + try { rels = parseXml(parts['_rels/.rels']); } catch { return parts; } + const main = mainTarget(rels); + if (!main) return parts; + const source = findPart(parts, main.path) ?? findPart(parts, encodeURI(main.path)); + if (!source) return parts; + const folder = source.slice(0, source.lastIndexOf('/') + 1); + // Only a rename within word/ keeps every relative target the part and its relationships hold. + if (source !== MAIN && (folder.toLowerCase() !== 'word/' || Object.hasOwn(parts, MAIN))) return parts; + let types; + try { types = parseXml(parts['[Content_Types].xml']); } catch { return parts; } + const result = { ...parts }; + if (source !== MAIN) { + result[MAIN] = parts[source]; delete result[source]; + const file = source.slice(folder.length), relsPart = findPart(parts, `${folder}_rels/${file}.rels`); + delete result['word/_rels/document.xml.rels']; + if (relsPart) { const bytes = parts[relsPart]; delete result[relsPart]; result['word/_rels/document.xml.rels'] = bytes; } + } + let changed = false; + const names = new Set([source, main.path, encodeURI(main.path)].map(name => `/${name}`.toLowerCase())); + for (const override of [...types.documentElement.children].filter(n => n.localName === 'Override')) { + if (names.has((override.getAttribute('PartName') || '').toLowerCase()) && override.getAttribute('PartName') !== `/${MAIN}`) { override.setAttribute('PartName', `/${MAIN}`); changed = true; } + } + if (changed) result['[Content_Types].xml'] = strToU8(new XMLSerializer().serializeToString(types)); + if (!/^\/?word\/document\.xml$/.test(main.link.getAttribute('Target'))) { + main.link.setAttribute('Target', MAIN); + result['_rels/.rels'] = strToU8(new XMLSerializer().serializeToString(rels)); + changed = true; + } + return changed || source !== MAIN ? result : parts; +} diff --git a/src/documents/src/engine/docx.js b/src/documents/src/engine/docx.js index c1d4092f4..24f17e8b5 100644 --- a/src/documents/src/engine/docx.js +++ b/src/documents/src/engine/docx.js @@ -93,6 +93,7 @@ import { validateIndent, normalizeTabStops } from './paragraph-geometry.js'; import { DEFAULT_PAGE_SETTINGS, validatePageSettings } from './page-settings.js'; import { checkedLineNumbers } from './line-number-settings.js'; import { zipPackage, MAX_PACKAGE_BYTES } from './package-part-paths.js'; +import { canonicalMainPart } from './docx-main-part.js'; import { unzipSync, strToU8 } from 'fflate'; import { paragraph, paragraphFormat, runFormat, documentText, paragraphText, PAGINATION_DEFAULTS } from './model.js'; @@ -1150,6 +1151,7 @@ export class DocxPackage { return true; } }); } catch (e) { throw new Error(`Could not read this DOCX archive: ${e.message}`); } + parts = canonicalMainPart(parts); if (!parts['word/document.xml'] || !parts['[Content_Types].xml'] || !parts['_rels/.rels']) throw new Error('This file is not a supported DOCX document.'); const types = xml(parts['[Content_Types].xml']); if (![...types.getElementsByTagNameNS('*', 'Override')].some(n => n.getAttribute('PartName') === '/word/document.xml' && MAIN_TYPES.has(n.getAttribute('ContentType')))) { diff --git a/src/documents/tests/docx-main-part.test.js b/src/documents/tests/docx-main-part.test.js new file mode 100644 index 000000000..b552fb490 --- /dev/null +++ b/src/documents/tests/docx-main-part.test.js @@ -0,0 +1,68 @@ +import { beforeAll, afterAll, it, expect } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate'; +import { DocxPackage } from '../src/engine/docx.js'; +import { TextDocument } from '../src/engine/model.js'; + +// OPC finds the main document part through the package relationship of type …/officeDocument +// (ECMA-376 Part 2 §8.3, Part 1 §11.3.10), not by its name, and part names compare without regard to +// ASCII case (Part 2 §9.1.1.1). Packages written by the Open XML SDK can name it word/document2.xml; +// others write the target as ./word/document.xml or capitalize the content type entry differently. +let dom; +beforeAll(() => { dom = new JSDOM(); globalThis.DOMParser = dom.window.DOMParser; globalThis.XMLSerializer = dom.window.XMLSerializer; }); +afterAll(() => dom.window.close()); + +const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; +const OFFICE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'; +const MAIN_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml'; +function packageWith({ main = 'word/document.xml', target = main, overrideName = `/${main}` } = {}) { + const file = main.slice(main.lastIndexOf('/') + 1); + return zipSync(Object.fromEntries(Object.entries({ + '[Content_Types].xml': ``, + '_rels/.rels': ``, + [`word/_rels/${file}.rels`]: ``, + [main]: `ReportBody text`, + 'word/styles.xml': ``, + }).map(([name, text]) => [name, strToU8(text)]))); +} +const text = pkg => pkg.paragraphs.map(p => p.runs.map(r => r.text).join('')); +async function editAndSave(pkg) { + const doc = new TextDocument(pkg.paragraphs, pkg.pageSettings, pkg.numbering, pkg.styles, pkg.tables, pkg.assets); + doc.select(doc.text.length); doc.replace(' added'); + return unzipSync(new Uint8Array(await pkg.export(doc.paragraphs, doc.pageSettings, doc.numbering, doc.styles, doc.tables, doc.assets).arrayBuffer())); +} + +for (const [name, options] of [ + ['a main part named word/document2.xml', { main: 'word/document2.xml' }], + ['a main part target written as ./word/document.xml', { target: './word/document.xml' }], + ['a main part target written with a leading slash and other capitals', { main: 'word/Document.xml', target: '/word/Document.xml' }], + ['a content type entry that capitalizes the main part name', { overrideName: '/word/Document.xml' }], +]) it(`opens ${name} for editing and saves it as word/document.xml`, async () => { + const pkg = await DocxPackage.open(packageWith(options)); + expect(pkg.readOnly).toBeFalsy(); + expect(text(pkg)).toEqual(['Report', 'Body text']); + // Its relationships still resolve: the heading keeps its style. + expect(pkg.paragraphs[0].styleId).toBe('Heading1'); + const parts = await editAndSave(pkg); + expect(Object.keys(parts).filter(key => /^word\/[^/]*document[^/]*\.xml$/i.test(key))).toEqual(['word/document.xml']); + expect(Object.keys(parts).filter(key => /^word\/_rels\//.test(key))).toEqual(['word/_rels/document.xml.rels']); + expect(strFromU8(parts['[Content_Types].xml'])).toContain(``); + expect(strFromU8(parts['_rels/.rels'])).toMatch(/Target="\/?word\/document\.xml"/); + const reopened = await DocxPackage.open(zipSync(parts)); + expect(reopened.readOnly).toBeFalsy(); + expect(text(reopened)).toEqual(['Report', 'Body text added']); + expect(reopened.paragraphs[0].styleId).toBe('Heading1'); +}); + +it('leaves the package parts of a main part already at word/document.xml as they were', async () => { + const bytes = packageWith(), source = unzipSync(bytes); + const parts = await editAndSave(await DocxPackage.open(bytes)); + for (const name of ['[Content_Types].xml', '_rels/.rels', 'word/_rels/document.xml.rels', 'word/styles.xml']) expect(strFromU8(parts[name]), name).toBe(strFromU8(source[name])); +}); + +it('still refuses a main part outside the word folder or one that competes with word/document.xml', async () => { + await expect(DocxPackage.open(packageWith({ main: 'content/main.xml' }))).rejects.toThrow(); + const parts = unzipSync(packageWith({ main: 'word/document2.xml' })); + parts['word/document.xml'] = parts['word/document2.xml']; + await expect(DocxPackage.open(zipSync(parts))).rejects.toThrow(); +}); From a414fa8cd3d1a6ecf2c41f82d6e865da2ba6d6f5 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:37:41 -0700 Subject: [PATCH 038/186] Documents: Say that a password-protected or Word 97-2003 file must be resaved, instead of calling it damaged Both are OLE compound files rather than ZIP packages and failed with "Could not read this DOCX archive: invalid zip data". --- src/documents/docs/conformance.md | 1 + src/documents/src/engine/docx.js | 4 ++- .../src/engine/package-part-paths.js | 21 +++++++++++++ src/documents/tests/docx-open-errors.test.js | 30 +++++++++++++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 src/documents/tests/docx-open-errors.test.js diff --git a/src/documents/docs/conformance.md b/src/documents/docs/conformance.md index 6530b19f9..21483d5f7 100644 --- a/src/documents/docs/conformance.md +++ b/src/documents/docs/conformance.md @@ -24394,3 +24394,4 @@ reason in the document itself. Each row has a unit regression that fails without | Tracked field code | §17.13.5.18 `ins`, §17.13.5.14 `del` (CT_RunTrackChange: runs, no `fldSimple`), §17.16.18 `fldChar`, §17.16.23 `instrText`, §17.16 `delInstrText` (deleted field code), §17.3.3.8 `delText` | A field inserted or deleted with Track Changes on has its code (begin, instruction, separator and end delimiters) inside the `w:ins` or `w:del`, and a deleted field's instruction is `w:delInstrText`. `w:delInstrText` made the whole document open read-only ("visible content inside a body field instruction"). A tracked field opened editable lost its tracking on save: the delimiters were written outside the change, so rejecting an inserted field (or accepting a deleted one), here or in Word, left an empty field behind, and accepting or rejecting here did the same. A field whose begin, separator and end delimiters sit in one change now keeps that change; saving writes its delimiters (and `w:delInstrText` for a deletion) inside the change, merged with its result; accepting or rejecting the change, at the caret, over a selection or for all changes, removes or keeps the whole field, and a deletion of another reviewer's insertion resolves as its text does. A field only partly inside a change keeps the earlier behaviour. Unit regression `tests/tracked-field-codes.test.js` (inserted, deleted and deleted-inserted fields: open, byte-identical save, delimiters inside the change after an edit, reopen, accept/reject all, at the caret and by reviewer, undo); edited exports pass `check:conformance`. Gaps: tracked fields in headers, footers and text boxes are not handled; a tracked field with an empty result (an index entry) is resolved only by resolving a stretch or all changes, and is not listed as a change of its own. | | Fields inserted or deleted while tracking | §17.13.5.18 `ins`, §17.13.5.14 `del`, §17.16.18 `fldChar` | With Track Changes on, inserting a field (Date and Time, Field, page numbers placed in the body, captions and cross-references) marked only its result inserted, and deleting a selection that took a whole field marked only its result deleted. Saved, the field's delimiters sat outside the change, so rejecting the insertion (or accepting the deletion) in Word, or here after reopening, left an empty field. The field's code is now part of the change: a field inserted while tracking (or added by a command whose insertion is then tracked) records the insertion; a deletion that takes a whole field, by the same rule an untracked deletion removes it (the selection reaches past at least one end), records the deletion, nested in another reviewer's insertion where there is one; the author's own inserted field is removed outright. Selecting exactly the result and deleting it deletes only the result, as it does untracked. Unit regression `tests/tracked-field-codes.test.js` (tracked insert, tracked command insertion, whole-field deletion saved, reopened and accepted, result-only deletion, own and other reviewers' fields). | | Main document part names | ECMA-376 Part 2 §8.3 package relationships, §9.1.1.1 part names (compared without regard to ASCII case), §10.1.2 content types; Part 1 §11.3.10 main document part | The main document part is the target of the package's `…/officeDocument` relationship, whatever its name. Packages whose main part is `word/document2.xml` (written by the Open XML SDK), whose relationship targets `./word/document.xml`, or whose relationship or `Override` names the part with other capitals (`/word/Document.xml`) were refused outright ("This file is not a supported DOCX document", "Unsupported DOCX document location", "Only standard .docx files are supported"). A main part stored under another name directly in `word/` is now renamed to `word/document.xml` on opening, with its relationships part, and the package relationship and content type entry are pointed at the new name; relative targets keep resolving because the folder does not change. A package whose main part is already `word/document.xml` is not rewritten. A main part in another folder, or one that competes with an existing `word/document.xml`, is still refused. Unit regression `tests/docx-main-part.test.js` (four variants: editable open, styles resolve, saved names, reopen; untouched package parts byte for byte; refusals). | +| Password-protected and Word 97–2003 files named .docx | [MS-CFB] §2.2 compound file header signature; [MS-OFFCRYPTO] §2.3.4.4 `EncryptedPackage` stream; [MS-DOC] `WordDocument` stream | A password-protected Word document is an OLE compound file holding the encrypted package, not a ZIP package, and so is a Word 97–2003 document saved with a `.docx` name. Both failed with "Could not read this DOCX archive: invalid zip data". A compound file is now recognized by its signature and the message says what to do: remove the password in Word, or save the file as `.docx` there. Nothing is decrypted or converted. Unit regression `tests/docx-open-errors.test.js`. | diff --git a/src/documents/src/engine/docx.js b/src/documents/src/engine/docx.js index 24f17e8b5..7848beca2 100644 --- a/src/documents/src/engine/docx.js +++ b/src/documents/src/engine/docx.js @@ -92,7 +92,7 @@ import { readNumbering, writeNumbering } from './docx-numbering.js'; import { validateIndent, normalizeTabStops } from './paragraph-geometry.js'; import { DEFAULT_PAGE_SETTINGS, validatePageSettings } from './page-settings.js'; import { checkedLineNumbers } from './line-number-settings.js'; -import { zipPackage, MAX_PACKAGE_BYTES } from './package-part-paths.js'; +import { zipPackage, MAX_PACKAGE_BYTES, compoundFileProblem } from './package-part-paths.js'; import { canonicalMainPart } from './docx-main-part.js'; import { unzipSync, strToU8 } from 'fflate'; import { paragraph, paragraphFormat, runFormat, documentText, paragraphText, PAGINATION_DEFAULTS } from './model.js'; @@ -1143,6 +1143,8 @@ export class DocxPackage { static async open(input, { bestEffort = false } = {}) { const bytes = input instanceof Uint8Array ? input : new Uint8Array(await input.arrayBuffer()); if (bytes.length > MAX_PACKAGE_BYTES) throw new Error('This editor currently opens DOCX files up to 20 MB.'); + const compound = compoundFileProblem(bytes); + if (compound) throw new Error(compound); let total = 0, count = 0, parts; try { parts = unzipSync(bytes, { filter: entry => { diff --git a/src/documents/src/engine/package-part-paths.js b/src/documents/src/engine/package-part-paths.js index 39000c928..67f0505a4 100644 --- a/src/documents/src/engine/package-part-paths.js +++ b/src/documents/src/engine/package-part-paths.js @@ -47,3 +47,24 @@ export function zipPackage(parts) { const stored = zipSync(Object.fromEntries(Object.entries(parts).map(([name, bytes]) => [name, PICTURE_PARTS.test(name) && incompressible(bytes) ? [bytes, { level: 0 }] : bytes]))); return stored.length > MAX_PACKAGE_BYTES ? zipSync(parts) : stored; } + +const CFB_SIGNATURE = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]; +const utf16 = text => Uint8Array.from([...text].flatMap(c => [c.charCodeAt(0), 0])); +function contains(bytes, needle) { + outer: for (let i = bytes.indexOf(needle[0]); i !== -1 && i <= bytes.length - needle.length; i = bytes.indexOf(needle[0], i + 1)) { + for (let j = 1; j < needle.length; j++) if (bytes[i + j] !== needle[j]) continue outer; + return true; + } + return false; +} +/** + * Why bytes that are an OLE compound file ([MS-CFB] §2.2) rather than a ZIP package cannot open, or + * null. Office keeps a password-protected document in one ([MS-OFFCRYPTO] §2.3.4.4, its + * EncryptedPackage stream), as it does a Word 97–2003 document ([MS-DOC], its WordDocument stream). + */ +export function compoundFileProblem(bytes) { + if (bytes.length < 512 || CFB_SIGNATURE.some((b, i) => bytes[i] !== b)) return null; + if (contains(bytes, utf16('EncryptedPackage'))) return 'This document is protected with a password. Open it in Word, remove the password, save it, and open it here again.'; + if (contains(bytes, utf16('WordDocument'))) return 'This is a Word 97–2003 document (.doc) with a .docx name. Save it as a Word Document (.docx) in Word, then open it here.'; + return 'This file is not a DOCX package. It may be another Office format saved with a .docx name.'; +} diff --git a/src/documents/tests/docx-open-errors.test.js b/src/documents/tests/docx-open-errors.test.js new file mode 100644 index 000000000..d4a663911 --- /dev/null +++ b/src/documents/tests/docx-open-errors.test.js @@ -0,0 +1,30 @@ +import { it, expect } from 'vitest'; +import { DocxPackage } from '../src/engine/docx.js'; + +// A password-protected .docx is not a ZIP package: Office stores the encrypted package in an OLE +// compound file ([MS-OFFCRYPTO] §2.3.4.4, the EncryptedPackage stream), as it stores a Word 97–2003 +// document ([MS-CFB] §2.2 header signature, [MS-DOC] WordDocument stream). Both used to fail with +// "Could not read this DOCX archive: invalid zip data", which tells the user nothing they can act on. +function compoundFile(streams) { + const bytes = new Uint8Array(4096); + bytes.set([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]); + // Directory entries hold their names in UTF-16LE (MS-CFB §2.6.1); one per 128-byte slot. + streams.forEach((name, i) => { for (let c = 0; c < name.length; c++) bytes[1024 + i * 128 + c * 2] = name.charCodeAt(c); }); + return bytes; +} + +it('says a password-protected document must have its password removed', async () => { + const error = await DocxPackage.open(compoundFile(['Root Entry', 'EncryptedInfo', 'EncryptedPackage', '\u0006DataSpaces'])).catch(e => e); + expect(error.message).toMatch(/password/i); + expect(error.message).not.toMatch(/invalid zip data/); +}); + +it('says a Word 97–2003 document saved with a .docx name must be saved as .docx first', async () => { + const error = await DocxPackage.open(compoundFile(['Root Entry', 'WordDocument', '1Table', '\u0005SummaryInformation'])).catch(e => e); + expect(error.message).toMatch(/Word 97/); + expect(error.message).not.toMatch(/invalid zip data/); +}); + +it('still reports other unreadable files as damaged archives', async () => { + await expect(DocxPackage.open(new TextEncoder().encode('not a zip'))).rejects.toThrow('Could not read this DOCX archive'); +}); From 86ab1e574d28c666f3be31b235733eb00f82a493 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:42:07 -0700 Subject: [PATCH 039/186] Documents: Show the text of HTML and merged documents imported with altChunk instead of an empty page Files from html-docx-js and document-assembly tools hold their content in an altChunk part (HTML, MIME HTML, text or another .docx) that the reader must convert. They open read-only, and the preview showed nothing. The preview now shows the chunk's paragraphs, with bold and italic, where it stands. --- src/documents/docs/conformance.md | 1 + src/documents/src/engine/docx-alt-chunks.js | 131 ++++++++++++++++++ src/documents/src/engine/docx-tables.js | 2 +- src/documents/src/engine/docx.js | 14 +- src/documents/tests/alt-chunk-preview.test.js | 65 +++++++++ 5 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 src/documents/src/engine/docx-alt-chunks.js create mode 100644 src/documents/tests/alt-chunk-preview.test.js diff --git a/src/documents/docs/conformance.md b/src/documents/docs/conformance.md index 21483d5f7..f5f5aed7a 100644 --- a/src/documents/docs/conformance.md +++ b/src/documents/docs/conformance.md @@ -24395,3 +24395,4 @@ reason in the document itself. Each row has a unit regression that fails without | Fields inserted or deleted while tracking | §17.13.5.18 `ins`, §17.13.5.14 `del`, §17.16.18 `fldChar` | With Track Changes on, inserting a field (Date and Time, Field, page numbers placed in the body, captions and cross-references) marked only its result inserted, and deleting a selection that took a whole field marked only its result deleted. Saved, the field's delimiters sat outside the change, so rejecting the insertion (or accepting the deletion) in Word, or here after reopening, left an empty field. The field's code is now part of the change: a field inserted while tracking (or added by a command whose insertion is then tracked) records the insertion; a deletion that takes a whole field, by the same rule an untracked deletion removes it (the selection reaches past at least one end), records the deletion, nested in another reviewer's insertion where there is one; the author's own inserted field is removed outright. Selecting exactly the result and deleting it deletes only the result, as it does untracked. Unit regression `tests/tracked-field-codes.test.js` (tracked insert, tracked command insertion, whole-field deletion saved, reopened and accepted, result-only deletion, own and other reviewers' fields). | | Main document part names | ECMA-376 Part 2 §8.3 package relationships, §9.1.1.1 part names (compared without regard to ASCII case), §10.1.2 content types; Part 1 §11.3.10 main document part | The main document part is the target of the package's `…/officeDocument` relationship, whatever its name. Packages whose main part is `word/document2.xml` (written by the Open XML SDK), whose relationship targets `./word/document.xml`, or whose relationship or `Override` names the part with other capitals (`/word/Document.xml`) were refused outright ("This file is not a supported DOCX document", "Unsupported DOCX document location", "Only standard .docx files are supported"). A main part stored under another name directly in `word/` is now renamed to `word/document.xml` on opening, with its relationships part, and the package relationship and content type entry are pointed at the new name; relative targets keep resolving because the folder does not change. A package whose main part is already `word/document.xml` is not rewritten. A main part in another folder, or one that competes with an existing `word/document.xml`, is still refused. Unit regression `tests/docx-main-part.test.js` (four variants: editable open, styles resolve, saved names, reopen; untouched package parts byte for byte; refusals). | | Password-protected and Word 97–2003 files named .docx | [MS-CFB] §2.2 compound file header signature; [MS-OFFCRYPTO] §2.3.4.4 `EncryptedPackage` stream; [MS-DOC] `WordDocument` stream | A password-protected Word document is an OLE compound file holding the encrypted package, not a ZIP package, and so is a Word 97–2003 document saved with a `.docx` name. Both failed with "Could not read this DOCX archive: invalid zip data". A compound file is now recognized by its signature and the message says what to do: remove the password in Word, or save the file as `.docx` there. Nothing is decrypted or converted. Unit regression `tests/docx-open-errors.test.js`. | +| Imported external content in the preview | ECMA-376 Part 1 §17.17.2.1 `altChunk`, §11.3.1 Alternative Format Import part; RFC 2557 MIME HTML, RFC 2045 transfer encodings | html-docx-js and document-assembly tools that merge files write a whole document as a `w:altChunk` naming an HTML page, a MIME HTML archive, plain text or another WordprocessingML package, and leave its conversion to the consumer. Such a document opens read-only (the chunk is not converted), and its preview was an empty page. The read-only preview now shows the chunk's text where the `altChunk` stands, in the body or a table cell: HTML block elements become paragraphs with bold and italic runs (the first `text/html` part of a MIME HTML archive, quoted-printable or base64, with its declared charset), plain text becomes one paragraph per line, and an embedded package shows its body text as the plain-text preview does. Scripts, styles and pictures in the HTML are not shown, nothing is fetched, and saving still keeps the original file. RTF chunks and chunks in headers or footers stay out of the preview. Unit regression `tests/alt-chunk-preview.test.js` (MIME HTML as html-docx-js writes it, a windows-1252 page, text, an embedded package in a table cell, unreadable chunks). | diff --git a/src/documents/src/engine/docx-alt-chunks.js b/src/documents/src/engine/docx-alt-chunks.js new file mode 100644 index 000000000..e0db11b79 --- /dev/null +++ b/src/documents/src/engine/docx-alt-chunks.js @@ -0,0 +1,131 @@ +import { paragraph } from './model.js'; +import { parseXml } from './docx-xml.js'; +import { partPathForUrl, relationshipPartPath } from './package-part-paths.js'; + +/** + * A read-only projection of imported external content (ECMA-376 Part 1 §17.17.2.1 `altChunk`, + * §17.17.2.2 `altChunkPr`; Part 1 §11.3.1 Alternative Format Import part). Document assembly tools + * (html-docx-js and others) put a whole HTML page, a MIME HTML archive, plain text or another + * WordprocessingML package in such a part and leave its conversion to the consumer. The editor does + * not convert it, so the document opens read-only; this shows the chunk's text and bold or italic + * runs in the preview instead of an empty page. Never serialize this approximation over the source. + */ +const R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'; +const REL = 'http://schemas.openxmlformats.org/package/2006/relationships'; +const CT = 'http://schemas.openxmlformats.org/package/2006/content-types'; +const AF_CHUNK = /\/aFChunk$/; + +function contentType(parts, path) { + let types; + try { types = parseXml(parts['[Content_Types].xml']).documentElement; } catch { return ''; } + const entries = [...types.children].filter(n => n.namespaceURI === CT || !n.namespaceURI); + const override = entries.find(n => n.localName === 'Override' && n.getAttribute('PartName')?.toLowerCase() === `/${path}`.toLowerCase()); + if (override) return (override.getAttribute('ContentType') || '').toLowerCase(); + const extension = path.slice(path.lastIndexOf('.') + 1).toLowerCase(); + return (entries.find(n => n.localName === 'Default' && n.getAttribute('Extension')?.toLowerCase() === extension)?.getAttribute('ContentType') || '').toLowerCase(); +} + +function decodeText(bytes, charset) { + if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) charset = 'utf-8'; + else if (bytes[0] === 0xff && bytes[1] === 0xfe) charset = 'utf-16le'; + else if (bytes[0] === 0xfe && bytes[1] === 0xff) charset = 'utf-16be'; + try { return new TextDecoder(charset || 'utf-8').decode(bytes); } catch { return new TextDecoder().decode(bytes); } +} +const latin1 = bytes => { let text = ''; for (let i = 0; i < bytes.length; i += 0x8000) text += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); return text; }; +const bytesOf = text => Uint8Array.from(text, c => c.charCodeAt(0) & 0xff); +const htmlCharset = bytes => /]+charset\s*=\s*["']?([\w-]+)/i.exec(latin1(bytes.subarray(0, 2048)))?.[1]; + +function headers(block) { + const result = {}; + for (const line of block.replace(/\r?\n[ \t]+/g, ' ').split(/\r?\n/)) { + const at = line.indexOf(':'); if (at > 0) result[line.slice(0, at).trim().toLowerCase()] = line.slice(at + 1).trim(); + } + return result; +} +const parameter = (value, name) => new RegExp(`(?:^|;)\\s*${name}\\s*=\\s*(?:"([^"]*)"|([^;\\s]*))`, 'i').exec(value || '')?.slice(1).find(v => v !== undefined); +function transferDecoded(body, encoding) { + encoding = (encoding || '').toLowerCase(); + if (encoding === 'base64') { try { return bytesOf(atob(body.replace(/[^A-Za-z0-9+/=]/g, ''))); } catch { return new Uint8Array(); } } + if (encoding === 'quoted-printable') return bytesOf(body.replace(/=\r?\n/g, '').replace(/=([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)))); + return bytesOf(body); +} +// MIME HTML (RFC 2557): the first text/html body part, decoded. +function mimeHtml(bytes) { + const text = latin1(bytes), split = /\r?\n\r?\n/.exec(text); + if (!split) return null; + const head = headers(text.slice(0, split.index)), body = text.slice(split.index + split[0].length); + const type = head['content-type'] || ''; + if (/^text\/html/i.test(type)) return decodeText(transferDecoded(body, head['content-transfer-encoding']), parameter(type, 'charset')); + const boundary = parameter(type, 'boundary'); + if (!/^multipart\//i.test(type) || !boundary) return null; + for (const section of body.split(`--${boundary}`).slice(1)) { + if (section.startsWith('--')) break; + const part = /\r?\n\r?\n/.exec(section); if (!part) continue; + const partHead = headers(section.slice(0, part.index).replace(/^\r?\n/, '')); + if (!/^text\/html/i.test(partHead['content-type'] || '')) continue; + return decodeText(transferDecoded(section.slice(part.index + part[0].length), partHead['content-transfer-encoding']), parameter(partHead['content-type'], 'charset')); + } + return null; +} + +// Text the model can hold: no XML-invalid characters, no object placeholders. +const clean = text => text.replace(/[^\u0009\u000b\u0020-\uD7FF\uE000-\uFFFB\uFFFD\u{10000}-\u{10FFFF}]/gu, ''); +const SKIPPED = new Set(['HEAD', 'SCRIPT', 'STYLE', 'TEMPLATE', 'NOSCRIPT', 'TITLE', 'META', 'LINK', 'XML']); +const BLOCKS = new Set(['ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'CAPTION', 'DD', 'DIV', 'DL', 'DT', 'FIGCAPTION', 'FIGURE', 'FOOTER', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'HR', 'LI', 'MAIN', 'NAV', 'OL', 'P', 'PRE', 'SECTION', 'TABLE', 'TBODY', 'THEAD', 'TFOOT', 'TR', 'UL']); +function htmlParagraphs(html) { + const doc = new DOMParser().parseFromString(html, 'text/html'), result = []; + let runs = []; + const flush = () => { + while (runs.length && !runs[0].text.trimStart()) runs.shift(); + if (runs.length) runs[0] = { ...runs[0], text: runs[0].text.trimStart() }; + if (runs.length) result.push(paragraph('', { runs })); + runs = []; + }; + const walk = (node, format, pre) => { + if (node.nodeType === 3) { + const text = clean(pre ? node.data.replace(/\r?\n/g, '\v') : node.data.replace(/\s+/g, ' ')); + if (text && !(text === ' ' && (!runs.length || runs.at(-1).text.endsWith(' ')))) runs.push({ text, ...format }); + return; + } + if (node.nodeType !== 1 || SKIPPED.has(node.tagName)) return; + const tag = node.tagName; + if (tag === 'BR') { runs.push({ text: '\v', ...format }); return; } + if ((tag === 'TD' || tag === 'TH') && node.previousElementSibling) runs.push({ text: '\t', ...format }); + if (['B', 'STRONG', 'TH'].includes(tag) || /^H[1-6]$/.test(tag)) format = { ...format, bold: true }; + if (['I', 'EM', 'CITE'].includes(tag)) format = { ...format, italic: true }; + const block = BLOCKS.has(tag); + if (block) flush(); + for (const child of node.childNodes) walk(child, format, pre || tag === 'PRE'); + if (block) flush(); + }; + walk(doc.body || doc.documentElement, {}, false); + flush(); + return result; +} + +/** + * The preview paragraphs for a `w:altChunk` in `sourcePart`, or null when its part cannot be + * found or holds a format this projection does not read. `previewPackage(bytes)` previews an + * embedded WordprocessingML package. + */ +export function altChunkParagraphs(parts, node, sourcePart, previewPackage) { + const id = node.getAttributeNS(R, 'id'); + if (!id) return null; + let path; + try { + const rels = parseXml(parts[relationshipPartPath(sourcePart)]); + const link = [...rels.documentElement.children].find(n => n.namespaceURI === REL && n.localName === 'Relationship' && n.getAttribute('Id') === id && AF_CHUNK.test(n.getAttribute('Type') || '')); + if (!link || link.getAttribute('TargetMode') === 'External') return null; + path = partPathForUrl(parts, new URL(link.getAttribute('Target'), `https://docx.invalid/${sourcePart}`)); + } catch { return null; } + const bytes = parts[path]; + if (!(bytes instanceof Uint8Array)) return null; + const type = contentType(parts, path); + if (type === 'message/rfc822' || /\.mht(ml)?$/i.test(path)) { const html = mimeHtml(bytes); return html === null ? null : htmlParagraphs(html); } + if (/^(text\/html|application\/xhtml\+xml)$/.test(type) || /\.x?html?$/i.test(path)) return htmlParagraphs(decodeText(bytes, htmlCharset(bytes))); + if (type === 'text/plain' || /\.txt$/i.test(path)) return decodeText(bytes).split(/\r\n|[\r\n]/).map(line => paragraph(clean(line))); + if (/wordprocessingml\.(document|template)|ms-word\.(document|template)\.macroenabled/.test(type) || /\.do[ct][xm]$/i.test(path)) { + try { return previewPackage(bytes); } catch { return null; } + } + return null; +} diff --git a/src/documents/src/engine/docx-tables.js b/src/documents/src/engine/docx-tables.js index 20c79d081..01ff7608f 100644 --- a/src/documents/src/engine/docx-tables.js +++ b/src/documents/src/engine/docx-tables.js @@ -353,7 +353,7 @@ export function readTable(node, readParagraph, stylesRoot, readColor, readCellBl table.hasCellControls = true; tc = child(parsed.content, 'tc'); } - if (children(tc).some(n => n.namespaceURI !== W || !['tcPr', 'p', ...(readCellBlocks ? ['tbl', 'sdt', 'bookmarkStart', 'bookmarkEnd'] : [])].includes(n.localName)) || children(tc).filter(n => n.localName === 'tcPr').length > 1) fail('unsupported cell blocks'); + if (children(tc).some(n => n.namespaceURI !== W || !['tcPr', 'p', ...(readCellBlocks ? ['tbl', 'sdt', 'bookmarkStart', 'bookmarkEnd'] : []), ...(readCellBlocks && preview ? ['altChunk'] : [])].includes(n.localName)) || children(tc).filter(n => n.localName === 'tcPr').length > 1) fail('unsupported cell blocks'); // Cell-style shading applies when tcPr/shd is absent, not tblPr/shd // ([MS-OI29500] §17.4.32). Keep table paint for its separate spacing layer. const cell = { id: tableId('cell'), ...properties(cellProperties, child(tc, 'tcPr'), { ...cellBase, marginDefaults, margins: resolveCellMargins(marginDefaults, cellBase.margins), shading: cellBase.shading ?? 'auto' }, readColor, false, preview), shell: shell(tc, ['tcPr']) }; diff --git a/src/documents/src/engine/docx.js b/src/documents/src/engine/docx.js index 7848beca2..3cc5f4677 100644 --- a/src/documents/src/engine/docx.js +++ b/src/documents/src/engine/docx.js @@ -94,6 +94,7 @@ import { DEFAULT_PAGE_SETTINGS, validatePageSettings } from './page-settings.js' import { checkedLineNumbers } from './line-number-settings.js'; import { zipPackage, MAX_PACKAGE_BYTES, compoundFileProblem } from './package-part-paths.js'; import { canonicalMainPart } from './docx-main-part.js'; +import { altChunkParagraphs } from './docx-alt-chunks.js'; import { unzipSync, strToU8 } from 'fflate'; import { paragraph, paragraphFormat, runFormat, documentText, paragraphText, PAGINATION_DEFAULTS } from './model.js'; @@ -1122,6 +1123,16 @@ function hasPageStoryControls(root) { }); } +// The text of a WordprocessingML package imported into another (an altChunk), for a read-only preview. +function previewEmbeddedPackage(bytes) { + let total = 0, count = 0; + const parts = canonicalMainPart(unzipSync(bytes, { filter: entry => { + total += entry.originalSize; + if (++count > 2000 || total > 50 * 1024 * 1024) throw new Error('DOCX expanded size exceeds the editor limit.'); + return true; + } })); + return DocxPackage.preview(parts).paragraphs; +} export class DocxPackage { constructor(parts, paragraphs, originals = new Map(), pageSettings, numbering = emptyNumbering(), styles, tables = [], assets = {}) { this.assets = assets; @@ -1784,7 +1795,8 @@ export class DocxPackage { // promotes text to the body and invalidates the surrounding table. // Keep a source for the table reader even when strict paragraph import // failed before it could register an original. This is display-only. - const recovered = previewParagraphs(node); + const chunk = node.namespaceURI === W && node.localName === 'altChunk' && !context.pageStory ? altChunkParagraphs(parts, node, 'word/document.xml', previewEmbeddedPackage) : null; + const recovered = chunk || previewParagraphs(node); if (context.cellId && !recovered.length) recovered.push(paragraph()); // Only the table reader and story originals read it, so body blocks need none: a // recovered table has a paragraph per cell, and each copy of the whole table made diff --git a/src/documents/tests/alt-chunk-preview.test.js b/src/documents/tests/alt-chunk-preview.test.js new file mode 100644 index 000000000..266042ab5 --- /dev/null +++ b/src/documents/tests/alt-chunk-preview.test.js @@ -0,0 +1,65 @@ +import { beforeAll, afterAll, it, expect } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { zipSync, strToU8 } from 'fflate'; +import { DocxPackage } from '../src/engine/docx.js'; +import { docxWithParagraphs } from './helpers/docx.js'; + +// ECMA-376 Part 1 §17.17.2.1 altChunk imports an Alternative Format Import part (§11.3.1): HTML, MIME +// HTML, plain text or another WordprocessingML package that the consumer converts. html-docx-js and +// document-assembly tools that merge files write whole documents this way. The editor does not +// convert them, so such a document opens read-only, and its preview used to be an empty page. +let dom; +beforeAll(() => { dom = new JSDOM(); globalThis.DOMParser = dom.window.DOMParser; globalThis.XMLSerializer = dom.window.XMLSerializer; }); +afterAll(() => dom.window.close()); + +const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; +const R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'; +function chunkPackage(body, chunks) { + const rels = chunks.map(c => ``).join(''); + return zipSync({ + '[Content_Types].xml': strToU8(``), + '_rels/.rels': strToU8(``), + 'word/_rels/document.xml.rels': strToU8(`${rels}`), + 'word/document.xml': strToU8(`${body}`), + ...Object.fromEntries(chunks.map(c => [c.target.replace(/^\//, '').replace(/^(?!afchunk)/, 'word/'), typeof c.bytes === 'string' ? strToU8(c.bytes) : c.bytes])), + }); +} +const text = pkg => pkg.paragraphs.map(p => p.runs.map(r => r.text).join('')); + +// As html-docx-js writes it: a quoted-printable UTF-8 page in a MIME HTML archive at the package root. +const MHT = ['MIME-Version: 1.0', 'Content-Type: multipart/related;', ' type="text/html";', ' boundary="----=mhtDocumentPart"', '', '', '------=mhtDocumentPart', 'Content-Type: text/html;', ' charset="utf-8"', 'Content-Transfer-Encoding: quoted-printable', 'Content-Location: file:///C:/fake/document.html', '', + 'Ignored

    Quarterly report

    Sales in the caf=C3=A9 grew by 12 % and kept grow=', 'ing.

    • First
    • Second
    AB
    ', '', '------=mhtDocumentPart--', ''].join('\r\n'); + +it('previews the HTML page of a MIME HTML chunk instead of an empty page', async () => { + const pkg = await DocxPackage.open(chunkPackage('', [{ id: 'htmlChunk', target: '/afchunk.mht', bytes: MHT }]), { bestEffort: true }); + expect(pkg.readOnly).toBe(true); + expect(text(pkg)).toEqual(['Quarterly report', 'Sales in the café grew by 12 % and kept growing.', 'First', 'Second', 'A\tB']); + const runs = pkg.paragraphs[1].runs; + expect(runs.find(r => r.text === '12 %').bold).toBe(true); + expect(runs.find(r => r.text === 'kept').italic).toBe(true); + expect(pkg.paragraphs[0].runs[0].bold).toBe(true); +}); + +it('previews HTML, plain text and an embedded document in place among the other blocks', async () => { + const html = new Uint8Array([...strToU8('

    Na'), 0xef, ...strToU8('ve r'), 0xe9, ...strToU8('sum'), 0xe9, ...strToU8('

    ')]); + const body = 'BeforeAfter'; + const pkg = await DocxPackage.open(chunkPackage(body, [ + { id: 'c1', target: 'chunk1.htm', bytes: html }, + { id: 'c2', target: 'chunk2.txt', bytes: 'Line one\r\nLine two' }, + { id: 'c3', target: 'merged.docx', bytes: docxWithParagraphs([{ text: 'Merged heading', heading: true }, { text: 'Merged body' }]) }, + ]), { bestEffort: true }); + expect(pkg.readOnly).toBe(true); + expect(text(pkg)).toEqual(['Before', 'Naïve résumé', 'Line one', 'Line two', 'Merged heading', 'Merged body', '', 'After']); + // The embedded document's paragraphs stay in their table cell. + expect(pkg.paragraphs[4].cellId).toBeTruthy(); + expect(pkg.paragraphs[5].cellId).toBe(pkg.paragraphs[4].cellId); +}); + +it('keeps an unreadable or unknown chunk out of the preview without failing the document', async () => { + const pkg = await DocxPackage.open(chunkPackage('Kept', [ + { id: 'c1', target: 'chunk.rtf', bytes: '{\\rtf1 Hidden}' }, + { id: 'c2', target: 'broken.docx', bytes: 'not a zip' }, + ]), { bestEffort: true }); + expect(pkg.readOnly).toBe(true); + expect(text(pkg)).toEqual(['Kept']); +}); From a5ba5155b44a1fd62552b4a96dc35d03605143c6 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:53:32 -0700 Subject: [PATCH 040/186] =?UTF-8?q?Documents:=20Open=20documents=20numbere?= =?UTF-8?q?d=20=E4=B8=80=E3=80=81=E4=BA=8C=E3=80=81,=20=E2=91=A0=E2=91=A1?= =?UTF-8?q?=E2=91=A2,=20=EA=B0=80=EB=82=98=EB=8B=A4=20or=20First,=20Second?= =?UTF-8?q?=20for=20editing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Any list number format besides decimal, letters and Roman numerals opened the whole document read-only. Chinese, Japanese and Korean counting and enclosed numbers, full-width digits, English ordinals and words, and the stem, branch and kana sequences are now numbered, and their format is kept on save. --- src/documents/docs/conformance.md | 3 +- src/documents/src/engine/docx-numbering.js | 4 +- src/documents/src/engine/list-definitions.js | 5 +- src/documents/src/engine/list-numerals.js | 89 ++++++++++++++++++ src/documents/src/engine/numbering.js | 4 + src/documents/tests/canvas-numbering.test.js | 2 +- .../numbering-definition-recovery/format.docx | Bin 3594 -> 3588 bytes .../inherited.docx | Bin 3643 -> 3637 bytes .../override.docx | Bin 3613 -> 3604 bytes .../tests/imported-numbering-formats.test.js | 65 +++++++++++++ .../numbering_definition_recovery_fixture.py | 4 +- .../numbering_identity_recovery_fixture.py | 2 +- 12 files changed, 169 insertions(+), 9 deletions(-) create mode 100644 src/documents/src/engine/list-numerals.js create mode 100644 src/documents/tests/imported-numbering-formats.test.js diff --git a/src/documents/docs/conformance.md b/src/documents/docs/conformance.md index f5f5aed7a..6113b2429 100644 --- a/src/documents/docs/conformance.md +++ b/src/documents/docs/conformance.md @@ -3523,7 +3523,7 @@ styles and geometry. Conformance validation now includes `word/numbering.xml` in every fixture and export. Puter mock save/reload also preserves list state. This is a body-paragraph subset, not complete numbering or reference equivalence. -Picture bullets, linked numbering styles, custom/other numbering formats, +Picture bullets, linked numbering styles, custom numbering formats and the other formats not listed under "Imported list number formats" below, non-geometry level paragraph properties and unsupported decorative symbol formatting reject when referenced. Unused definitions remain preserved. Alphabetic output currently uses English A–Z; locale-specific @@ -24396,3 +24396,4 @@ reason in the document itself. Each row has a unit regression that fails without | Main document part names | ECMA-376 Part 2 §8.3 package relationships, §9.1.1.1 part names (compared without regard to ASCII case), §10.1.2 content types; Part 1 §11.3.10 main document part | The main document part is the target of the package's `…/officeDocument` relationship, whatever its name. Packages whose main part is `word/document2.xml` (written by the Open XML SDK), whose relationship targets `./word/document.xml`, or whose relationship or `Override` names the part with other capitals (`/word/Document.xml`) were refused outright ("This file is not a supported DOCX document", "Unsupported DOCX document location", "Only standard .docx files are supported"). A main part stored under another name directly in `word/` is now renamed to `word/document.xml` on opening, with its relationships part, and the package relationship and content type entry are pointed at the new name; relative targets keep resolving because the folder does not change. A package whose main part is already `word/document.xml` is not rewritten. A main part in another folder, or one that competes with an existing `word/document.xml`, is still refused. Unit regression `tests/docx-main-part.test.js` (four variants: editable open, styles resolve, saved names, reopen; untouched package parts byte for byte; refusals). | | Password-protected and Word 97–2003 files named .docx | [MS-CFB] §2.2 compound file header signature; [MS-OFFCRYPTO] §2.3.4.4 `EncryptedPackage` stream; [MS-DOC] `WordDocument` stream | A password-protected Word document is an OLE compound file holding the encrypted package, not a ZIP package, and so is a Word 97–2003 document saved with a `.docx` name. Both failed with "Could not read this DOCX archive: invalid zip data". A compound file is now recognized by its signature and the message says what to do: remove the password in Word, or save the file as `.docx` there. Nothing is decrypted or converted. Unit regression `tests/docx-open-errors.test.js`. | | Imported external content in the preview | ECMA-376 Part 1 §17.17.2.1 `altChunk`, §11.3.1 Alternative Format Import part; RFC 2557 MIME HTML, RFC 2045 transfer encodings | html-docx-js and document-assembly tools that merge files write a whole document as a `w:altChunk` naming an HTML page, a MIME HTML archive, plain text or another WordprocessingML package, and leave its conversion to the consumer. Such a document opens read-only (the chunk is not converted), and its preview was an empty page. The read-only preview now shows the chunk's text where the `altChunk` stands, in the body or a table cell: HTML block elements become paragraphs with bold and italic runs (the first `text/html` part of a MIME HTML archive, quoted-printable or base64, with its declared charset), plain text becomes one paragraph per line, and an embedded package shows its body text as the plain-text preview does. Scripts, styles and pictures in the HTML are not shown, nothing is fetched, and saving still keeps the original file. RTF chunks and chunks in headers or footers stay out of the preview. Unit regression `tests/alt-chunk-preview.test.js` (MIME HTML as html-docx-js writes it, a windows-1252 page, text, an embedded package in a table cell, unreadable chunks). | +| Imported list number formats | ECMA-376 Part 1 §17.9.17 `numFmt`, §17.18.59 ST_NumberFormat, §17.9.11 `lvlText` | A list level in any number format other than decimal, zero-padded decimal, letters, Roman numerals, bullets and none opened the whole document read-only ("Unsupported numbering format"), which took in most Chinese, Japanese and Korean documents (一、二、 ①②③ 가나다) and legal ones (First, 1st). These formats are now numbered and editable: `decimalHalfWidth`, `decimalFullWidth`, `decimalFullWidth2`, `decimalEnclosedCircle`, `decimalEnclosedCircleChinese` (①–㊿), `decimalEnclosedParen` and `decimalEnclosedFullstop` (1–20), `ordinal`, `cardinalText`, `ordinalText` (English, capitalized words), `chicago`, `chineseCounting` and `japaneseCounting` (below 100,000,000; Chinese writes 零 for skipped places and keeps 一 before 百 and 千, Japanese does neither), `ideographDigital`, `ideographTraditional` (甲–癸), `ideographZodiac` (子–亥), `aiueo` and `aiueoFullWidth` (46 kana), `ganada` and `chosung` (14), and `numberInDash`. §17.18.59 defines these sequences by example only; a value past a sequence's characters is written in decimal digits, an approximation of this editor. The format is kept in `numbering.xml` through edits and save; the list dialogs still offer only their own styles. Other formats (Hebrew, Arabic, Hindi, Thai, Korean counting, iroha, …) and custom `w:format` strings still open read-only. Unit regression `tests/imported-numbering-formats.test.js`; rejection fixtures that used `decimalEnclosedCircle` or `chicago` as their unsupported format now use `hebrew2`. | diff --git a/src/documents/src/engine/docx-numbering.js b/src/documents/src/engine/docx-numbering.js index 5123408ad..889de3f0f 100644 --- a/src/documents/src/engine/docx-numbering.js +++ b/src/documents/src/engine/docx-numbering.js @@ -15,7 +15,7 @@ import { readRunVertical } from './docx-vertical-align.js'; import { readRunBackground } from './docx-backgrounds.js'; import { parseXml as xml, setContentTypeOverride } from './docx-xml.js'; import { strToU8 } from 'fflate'; -import { emptyNumbering, NUMBER_FORMATS } from './numbering.js'; +import { emptyNumbering, READ_NUMBER_FORMATS } from './numbering.js'; import { relatedPartPath } from './docx-relationships.js'; import { writeFontProperties } from './docx-fonts.js'; import { LEVEL_PARAGRAPH_ELEMENTS } from './numbering-paragraph.js'; @@ -73,7 +73,7 @@ export function readNumbering(parts, readGeometry, readColor, readFonts, onError for (const n of children(node)) if (n.namespaceURI !== W || !['start', 'numFmt', 'lvlRestart', 'pStyle', 'isLgl', 'suff', 'lvlText', 'legacy', 'lvlJc', 'pPr', 'rPr'].includes(n.localName)) throw new Error(`Unsupported numbering property: ${n.localName}.`); const format = val(child(node, 'numFmt')) || 'decimal', textNode = child(node, 'lvlText'); let text = val(textNode) || ''; - if (!NUMBER_FORMATS.includes(format) || child(node, 'numFmt')?.hasAttributeNS(W, 'format')) throw new Error(`Unsupported numbering format: ${format}.`); + if (!READ_NUMBER_FORMATS.includes(format) || child(node, 'numFmt')?.hasAttributeNS(W, 'format')) throw new Error(`Unsupported numbering format: ${format}.`); if (['1', 'true', 'on'].includes(textNode?.getAttributeNS(W, 'null')) && !text) throw new Error('Null numbering symbols are not supported.'); if (text.length > 128 || /[\u0000-\u001f]/u.test(text)) throw new Error('Unsupported numbering label text.'); const alignment = { start: 'left', end: 'right' }[val(child(node, 'lvlJc'))] || val(child(node, 'lvlJc')) || 'left'; diff --git a/src/documents/src/engine/list-definitions.js b/src/documents/src/engine/list-definitions.js index ca9c3ec00..1919d75e5 100644 --- a/src/documents/src/engine/list-definitions.js +++ b/src/documents/src/engine/list-definitions.js @@ -1,4 +1,4 @@ -import { levelFor, listKind, newList, NUMBER_FORMATS, listMarkers } from './numbering.js'; +import { levelFor, listKind, newList, READ_NUMBER_FORMATS, listMarkers } from './numbering.js'; import { checkedFontEdit } from './font-families.js'; import { validFontSize } from './font-size.js'; import { normalizeColor } from './colors.js'; @@ -35,7 +35,8 @@ function checkedLevelPatch(patch) { const result = {}; for (const [key, value] of Object.entries(patch)) { if (key === 'format') { - if (!NUMBER_FORMATS.includes(value)) throw new Error('Choose a supported number style.'); + // A level read from a document keeps its own format when other properties change. + if (!READ_NUMBER_FORMATS.includes(value)) throw new Error('Choose a supported number style.'); result.format = value; } else if (key === 'text') { if (typeof value !== 'string' || value.length > 64 || /[\u0000-\u001f]/.test(value)) throw new Error('Enter a list label of up to 64 characters.'); diff --git a/src/documents/src/engine/list-numerals.js b/src/documents/src/engine/list-numerals.js new file mode 100644 index 000000000..92c78c728 --- /dev/null +++ b/src/documents/src/engine/list-numerals.js @@ -0,0 +1,89 @@ +/** + * List numbers in the ST_NumberFormat values (ECMA-376 Part 1 §17.18.59) that imported documents use + * beyond the ones the list dialogs offer: full-width and enclosed digits, English ordinals and words, + * Chinese and Japanese counting numerals and ideographic digits, the ten heavenly stems and twelve + * earthly branches, the Japanese aiueo syllabary, the Korean ganada and chosung sequences and dashes. §17.18.59 names each sequence by example only. A value outside + * the characters a sequence has (a circled number above 50, a stem above 10) is written as a decimal + * number, which is this editor's approximation, not a documented reference behavior. + */ +export const IMPORTED_NUMBER_FORMATS = Object.freeze([ + 'decimalHalfWidth', 'decimalFullWidth', 'decimalFullWidth2', 'decimalEnclosedCircle', 'decimalEnclosedCircleChinese', + 'decimalEnclosedFullstop', 'decimalEnclosedParen', 'ordinal', 'cardinalText', 'ordinalText', 'chicago', + 'chineseCounting', 'japaneseCounting', 'ideographDigital', 'ideographTraditional', 'ideographZodiac', 'aiueo', 'aiueoFullWidth', + 'ganada', 'chosung', 'numberInDash', +]); + +const ONES = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen']; +const TENS = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety']; +function words(n) { + if (n < 20) return ONES[n]; + if (n < 100) return TENS[Math.floor(n / 10)] + (n % 10 ? `-${ONES[n % 10]}` : ''); + if (n < 1000) return `${ONES[Math.floor(n / 100)]} hundred${n % 100 ? ` ${words(n % 100)}` : ''}`; + return `${words(Math.floor(n / 1000))} thousand${n % 1000 ? ` ${words(n % 1000)}` : ''}`; +} +const IRREGULAR_ORDINALS = { one: 'first', two: 'second', three: 'third', five: 'fifth', eight: 'eighth', nine: 'ninth', twelve: 'twelfth' }; +const ordinalWords = n => words(n).replace(/[a-z]+$/, w => IRREGULAR_ORDINALS[w] || (w.endsWith('y') ? `${w.slice(0, -1)}ieth` : `${w}th`)); +const capitalized = text => text.charAt(0).toUpperCase() + text.slice(1); + +const CJK_DIGITS = '〇一二三四五六七八九'; +// Counting numerals below one hundred million: 十 百 千 within each group of four, 万 between groups. +// Chinese keeps 一 before 百 and 千 and writes 零 for skipped places; Japanese does neither. +function countingGroup(value, chinese, first) { + let text = '', skipped = false, started = false; + for (const [unit, mark] of [[1000, '千'], [100, '百'], [10, '十'], [1, '']]) { + const digit = Math.floor(value / unit) % 10; + if (!digit) { if (started) skipped = true; continue; } + if (skipped && chinese) text += '零'; + skipped = false; + // 十 alone stands for 10–19 at the start of a Chinese number; Japanese drops 一 before every unit. + const omitOne = digit === 1 && mark && (!chinese || mark === '十' && first && !started); + text += (omitOne ? '' : CJK_DIGITS[digit]) + mark; + started = true; + } + return text; +} +function counting(n, chinese) { + const high = Math.floor(n / 10000), low = n % 10000; + if (!high) return countingGroup(low, chinese, true); + return `${countingGroup(high, chinese, true)}万${low ? (chinese && low < 1000 ? '零' : '') + countingGroup(low, chinese, false) : ''}`; +} + +const STEMS = '甲乙丙丁戊己庚辛壬癸', BRANCHES = '子丑寅卯辰巳午未申酉戌亥'; +const AIUEO = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン'; +const AIUEO_HALF = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン'; +const GANADA = '가나다라마바사아자차카타파하', CHOSUNG = 'ㄱㄴㄷㄹㅁㅂㅅㅇㅈㅊㅋㅌㅍㅎ'; +const inSequence = (sequence, value) => value >= 1 && value <= sequence.length ? sequence[value - 1] : String(value); +const enclosed = (base, value) => value >= 1 && value <= 20 ? String.fromCodePoint(base + value - 1) : String(value); +// Unicode encloses 1–20, 21–35 and 36–50 in circles in three separate blocks. +const circled = value => value >= 21 && value <= 35 ? String.fromCodePoint(0x3251 + value - 21) : value >= 36 && value <= 50 ? String.fromCodePoint(0x32b1 + value - 36) : enclosed(0x2460, value); + +/** The label for `value` in one of IMPORTED_NUMBER_FORMATS. */ +export function importedNumeral(value, format) { + switch (format) { + case 'decimalHalfWidth': return String(value); + case 'decimalFullWidth': case 'decimalFullWidth2': return String(value).replace(/[0-9-]/g, c => c === '-' ? '-' : String.fromCodePoint(0xff10 + Number(c))); + case 'decimalEnclosedCircle': case 'decimalEnclosedCircleChinese': return circled(value); + case 'decimalEnclosedParen': return enclosed(0x2474, value); + case 'decimalEnclosedFullstop': return enclosed(0x2488, value); + case 'ordinal': { const s = Math.abs(value) % 100; return `${value}${s >= 11 && s <= 13 ? 'th' : ['th', 'st', 'nd', 'rd'][Math.abs(value) % 10] || 'th'}`; } + case 'cardinalText': return value >= 0 && value <= 999999 ? capitalized(words(value)) : String(value); + case 'ordinalText': return value >= 1 && value <= 999999 ? capitalized(ordinalWords(value)) : String(value); + case 'chicago': { + // Note symbols: *, †, ‡, § and then doubled, tripled… + if (value < 1) return String(value); + const count = Math.floor((value - 1) / 4) + 1; + if (count > 128) throw new Error('This symbol list number is too long for the preview.'); + return ['*', '†', '‡', '§'][(value - 1) % 4].repeat(count); + } + case 'chineseCounting': case 'japaneseCounting': return value >= 1 && value < 100000000 ? counting(value, format === 'chineseCounting') : value === 0 ? '〇' : String(value); + case 'ideographDigital': return value >= 0 ? String(value).replace(/[0-9]/g, c => CJK_DIGITS[c]) : String(value); + case 'aiueo': return inSequence(AIUEO_HALF, value); + case 'aiueoFullWidth': return inSequence(AIUEO, value); + case 'numberInDash': return `- ${value} -`; + case 'ideographTraditional': return inSequence(STEMS, value); + case 'ideographZodiac': return inSequence(BRANCHES, value); + case 'ganada': return inSequence(GANADA, value); + case 'chosung': return inSequence(CHOSUNG, value); + default: return String(value); + } +} diff --git a/src/documents/src/engine/numbering.js b/src/documents/src/engine/numbering.js index c50b163bc..2f2359b31 100644 --- a/src/documents/src/engine/numbering.js +++ b/src/documents/src/engine/numbering.js @@ -1,8 +1,11 @@ import { normalizeTabStops } from './paragraph-geometry.js'; import { applyLevelParagraph } from './numbering-paragraph.js'; +import { IMPORTED_NUMBER_FORMATS, importedNumeral } from './list-numerals.js'; export const emptyNumbering = () => ({ abstracts: {}, instances: {} }); export const NUMBER_FORMATS = ['bullet', 'decimal', 'decimalZero', 'lowerLetter', 'upperLetter', 'lowerRoman', 'upperRoman', 'none']; +/** Formats read from documents and numbered in lists; the list dialogs offer NUMBER_FORMATS. */ +export const READ_NUMBER_FORMATS = Object.freeze([...NUMBER_FORMATS, ...IMPORTED_NUMBER_FORMATS]); export class NumberingDefinitionError extends Error {} export function levelFor(numbering, list) { const instance = numbering?.instances[list.numId], abstract = numbering?.abstracts[instance?.abstractId]; @@ -66,6 +69,7 @@ export function listGeometry(p, numbering, plain) { function numeral(value, format) { if (format === 'none') return ''; if (format === 'decimalZero') return String(value).padStart(2, '0'); + if (IMPORTED_NUMBER_FORMATS.includes(format)) return importedNumeral(value, format); if (/Letter$/.test(format)) { // OOXML alphabetic numbering repeats letters after Z: AA, BB, ... (§17.18.59). if (value < 1) return String(value); diff --git a/src/documents/tests/canvas-numbering.test.js b/src/documents/tests/canvas-numbering.test.js index 6c6c9ac3b..d2e708ff8 100644 --- a/src/documents/tests/canvas-numbering.test.js +++ b/src/documents/tests/canvas-numbering.test.js @@ -267,7 +267,7 @@ describe('numbering codec and preservation', () => { it.each(['lvlPicBulletId', 'numFmt', 'numStyleLink'])('rejects an unsupported referenced %s before attaching an editor', async kind => { await expect(rewrite(await fixture(), xmls => { const xml = xmls['word/numbering.xml'], base = nodes(xml, 'abstractNum').find(n => n.getAttributeNS(W, 'abstractNumId') === '41'); - if (kind === 'numFmt') nodes(base, 'numFmt')[0].setAttributeNS(W, 'w:val', 'chicago'); + if (kind === 'numFmt') nodes(base, 'numFmt')[0].setAttributeNS(W, 'w:val', 'hebrew2'); else if (kind === 'numStyleLink') base.append(el(xml, kind, 'Linked')); else nodes(base, 'lvl')[0].append(el(xml, kind, '1')); })).rejects.toMatchObject({ name: 'UnsupportedDocumentError' }); diff --git a/src/documents/tests/fixtures/numbering-definition-recovery/format.docx b/src/documents/tests/fixtures/numbering-definition-recovery/format.docx index 5d9a299b3bfbee6afeaf9d7088a7c9d3bfeebf72..ca9b2d89822eb6420aaebc623ff31dad20748488 100644 GIT binary patch delta 306 zcmV-20nPr39E2RO^#uvy@m$o)0000bll}!6f1Q%C3WG2ZhF?X5u8lf$5OplHL#Mt# zjAs>cQIgp7@q4CK6eIZY3u6o@GFov2aSrSDjhP+i_k-3zY#%eC?ZNOl)U8+2c{1f?<6ty%aVl3B| zOxTkXibFOsk~`xJD-bPm`H84`hu54P;^WM(VxkgiJ7VU!p9`D6s_|~s21*Ss*-+hO zs=T3EnyqxFOVRg?`cX@^Yw@zi7XPAn1Cw9}E|Z@O1+yCm76l37@m$o)0000blR6Ah z0pgR43_<}~lg|u70n(Ea4M_pblUxlr0)7dTpa~U|s|_jwo(hw|3LTU54G;!l3jhEB E07?#p5dZ)H delta 311 zcmV-70m%M@9Eu#U^#uvx-E@`F0000pll}!6f2ESK3c@fDhF?Vru1)LUP^yb4=-}iF zr0J=F+@&P3?c;l`wp36AosRqd{Kq#WZ!)@J0Z!9p)}kSwHLK_vLX`>O)fG_s*j7NC zovlL|cy$-iSXWlgD)640Xv88(mI9@wXq`8iEPQZMh2srpFWbIcMM?j})P^I?V<0|A zRu`Dnl2ICojcQIgp7@q4CK6eIZY3u6o@GFov2aSrSDjhP+i_k-3zY#%eC?ZNOl)U8+2c{1f?<6ty%aVl3B| zOxTkXibFOsk~`xJD-bPm`H84`hu54P;^WM(VxkgiJ7VU!p9`D6s_|~s21*Ss*-+hO zs=T3EnyqxFOVRg?`cX@^Yw@zi7XPAn1Cx;lE|bs<1+z>C76l37@m$o)0000blWPo7 z0TYwG3_<~mk_|!u1Cv7yNdo=|la38H0O)fG_s*j7NC zovlL|cy$-iSXWlgD)640Xv88(mI9@wXq`8iEPQZMh2srpFWbIcMM?j})P^I?V<0|A zQWu!jl2ICojVK?fTJ3E|yzmC*nI05g+i z3{e6Z2a_)c7n9EnDglv``V2w>2?&!P2p*GM4JrZz36qEoHvzSizzskF)sq1ZE(Vwj H00000@BoMy diff --git a/src/documents/tests/fixtures/numbering-definition-recovery/override.docx b/src/documents/tests/fixtures/numbering-definition-recovery/override.docx index d5b1487b35bbcad46852db681a365442b6b79309..431c3d88ba4365bc15a1f61706cb09e948153129 100644 GIT binary patch delta 311 zcmV-70m%NH9F!ce^#uuk6nJaf0002Sll}!9e+t4d3_yQH243y9gNLCW1wH7+ALzR1 z3QafCy88LX>E;wgP&|daG~p$57BYLp97x;4=!C|)EXMxAP*G7+rNBAvL?hBTo(h!OMvGawhw(-2KN4fMa$bbu=%K({7LmIQ`;Kaq1l6O zHz~f2u`k-v_icBnZ39HQfTyLuIYS9KK}df(!@%giml%B9#Tm2v21x}8e-wCY+W-In z#*6G;30002gll}!9e{RAs3_yPcRoa!bJ#eUi3mVdPV3++s zNDM@ZoyrLc`idK(jKlQu`3TxD-|k>%YJ-*_oG2|bBP zW^)qOXhB7$&P?RiI6w==q4wV;>HLJ#P5Cg)^yB~l0MwI23{e9629pj47n80GDgkqo*$hGg>j#tm2Og6<4JrZV alV%M#0hg1X4L||6li&?526_ts0000+0FTlD diff --git a/src/documents/tests/imported-numbering-formats.test.js b/src/documents/tests/imported-numbering-formats.test.js new file mode 100644 index 000000000..4b9df97b3 --- /dev/null +++ b/src/documents/tests/imported-numbering-formats.test.js @@ -0,0 +1,65 @@ +import { beforeAll, afterAll, it, expect } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate'; +import { DocxPackage } from '../src/engine/docx.js'; +import { TextDocument } from '../src/engine/model.js'; +import { listMarkers } from '../src/engine/numbering.js'; +import { importedNumeral } from '../src/engine/list-numerals.js'; + +// ST_NumberFormat (ECMA-376 Part 1 §17.18.59) lists many more number styles than the list dialogs offer. +// Chinese and Japanese documents number with 一、二、三 and ①②③, legal documents with "First" or "1st", +// Korean documents with 가나다. A document using any of them opened read-only ("Unsupported numbering +// format"). They are now numbered and the document is editable; the format is kept on save. +let dom; +beforeAll(() => { dom = new JSDOM(); globalThis.DOMParser = dom.window.DOMParser; globalThis.XMLSerializer = dom.window.XMLSerializer; }); +afterAll(() => dom.window.close()); + +const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; +const R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'; +const FORMATS = [['chineseCounting', '%1、'], ['decimalEnclosedCircle', '%1'], ['ordinalText', '%1:'], ['ganada', '%1.'], ['japaneseCounting', '第%1条'], ['decimalFullWidth', '%1.']]; +function listPackage(count = 3) { + const abstracts = FORMATS.map(([format, text], i) => ``).join(''); + const nums = FORMATS.map((_, i) => ``).join(''); + const body = FORMATS.flatMap((_, i) => Array.from({ length: count }, (__, n) => `Item ${n + 1}`)).join(''); + return zipSync(Object.fromEntries(Object.entries({ + '[Content_Types].xml': ``, + '_rels/.rels': ``, + 'word/_rels/document.xml.rels': ``, + 'word/numbering.xml': `${abstracts}${nums}`, + 'word/document.xml': `${body}`, + }).map(([name, xml]) => [name, strToU8(xml)]))); +} +const labels = pkg => listMarkers(pkg.paragraphs, pkg.numbering).map(m => m?.text); + +it('numbers lists in Chinese, Japanese, Korean, enclosed, full-width and English word formats and opens them for editing', async () => { + const pkg = await DocxPackage.open(listPackage()); + expect(pkg.readOnly).toBeFalsy(); + expect(labels(pkg)).toEqual(['一、', '二、', '三、', '①', '②', '③', 'First:', 'Second:', 'Third:', '가.', '나.', '다.', '第一条', '第二条', '第三条', '1.', '2.', '3.']); +}); + +it('keeps each imported format through an edit, a new item and a reopen', async () => { + const pkg = await DocxPackage.open(listPackage(2)); + const doc = new TextDocument(pkg.paragraphs, pkg.pageSettings, pkg.numbering, pkg.styles, pkg.tables, pkg.assets); + // A new item after the second Chinese item continues that list. + doc.select(doc.text.indexOf('Item 2') + 6); doc.insertParagraph(); doc.replace('Item 3'); + const parts = unzipSync(new Uint8Array(await pkg.export(doc.paragraphs, doc.pageSettings, doc.numbering, doc.styles, doc.tables, doc.assets).arrayBuffer())); + const numbering = strFromU8(parts['word/numbering.xml']); + for (const [format] of FORMATS) expect(numbering).toContain(``); + const reopened = await DocxPackage.open(zipSync(parts)); + expect(reopened.readOnly).toBeFalsy(); + expect(labels(reopened).slice(0, 5)).toEqual(['一、', '二、', '三、', '①', '②']); +}); + +it('counts in Chinese and Japanese numerals and falls back to digits past a sequence', () => { + const cases = [[10, '十', '十'], [11, '十一', '十一'], [20, '二十', '二十'], [100, '一百', '百'], [101, '一百零一', '百一'], [110, '一百一十', '百十'], [1010, '一千零一十', '千十'], [10001, '一万零一', '一万一'], [100000, '十万', '十万']]; + for (const [value, chinese, japanese] of cases) { + expect(importedNumeral(value, 'chineseCounting'), String(value)).toBe(chinese); + expect(importedNumeral(value, 'japaneseCounting'), String(value)).toBe(japanese); + } + expect([1, 20, 21, 35, 36, 50, 51].map(n => importedNumeral(n, 'decimalEnclosedCircle'))).toEqual(['①', '⑳', '㉑', '㉟', '㊱', '㊿', '51']); + expect(importedNumeral(21, 'decimalEnclosedParen')).toBe('21'); + expect(importedNumeral(10, 'ideographTraditional')).toBe('癸'); + expect(importedNumeral(11, 'ideographTraditional')).toBe('11'); + expect(['ordinal', 'cardinalText', 'ordinalText'].map(f => importedNumeral(21, f))).toEqual(['21st', 'Twenty-one', 'Twenty-first']); + expect(importedNumeral(2024, 'ideographDigital')).toBe('二〇二四'); +}); diff --git a/src/documents/tools/numbering_definition_recovery_fixture.py b/src/documents/tools/numbering_definition_recovery_fixture.py index e65e56ccb..dd0762a0d 100644 --- a/src/documents/tools/numbering_definition_recovery_fixture.py +++ b/src/documents/tools/numbering_definition_recovery_fixture.py @@ -28,9 +28,9 @@ def paragraph(number): if variant == 'inherited': style = '' parts['word/styles.xml'] = parts['word/styles.xml'].decode().replace('', style + '').encode() - fmt = 'decimalEnclosedCircle' if variant in ['format', 'inherited'] else 'decimal' + fmt = 'hebrew2' if variant in ['format', 'inherited'] else 'decimal' level = f'' - override = '' + level.replace('w:val="decimal"', 'w:val="decimalEnclosedCircle"') + '' if variant == 'override' else '' + override = '' + level.replace('w:val="decimal"', 'w:val="hebrew2"') + '' if variant == 'override' else '' instance = '' if variant == 'missing' else f'{override}' parts['word/numbering.xml'] = parts['word/numbering.xml'].decode().replace('', f'{level}').replace('', instance + '').encode() with ZipFile(OUT / (variant + '.docx'), 'w', ZIP_DEFLATED) as archive: diff --git a/src/documents/tools/numbering_identity_recovery_fixture.py b/src/documents/tools/numbering_identity_recovery_fixture.py index 2bbede464..39d4b47d4 100644 --- a/src/documents/tools/numbering_identity_recovery_fixture.py +++ b/src/documents/tools/numbering_identity_recovery_fixture.py @@ -16,7 +16,7 @@ def main(): OUT.mkdir(exist_ok=True) with ZipFile(ROOT / 'tests/fixtures/numbering-definition-recovery/format.docx') as archive: source = {name: archive.read(name) for name in archive.namelist()} - numbering = source['word/numbering.xml'].decode().replace('decimalEnclosedCircle', 'decimal') + numbering = source['word/numbering.xml'].decode().replace('hebrew2', 'decimal') abstract = re.search(r'.*?', numbering).group() instance = '' variants = { From 5e339fec48be95c8bd7c83d7429016f32bab0ee6 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:57:58 -0700 Subject: [PATCH 041/186] Documents: Name an unsupported style or list feature once in the read-only notice A style or list definition that cannot be read keeps its reason as text; when a paragraph used it, the reason was wrapped again, so the notice read "This document contains This document contains ..., which ...". --- src/documents/src/engine/errors.js | 3 ++ .../unsupported-document-messages.test.js | 39 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 src/documents/tests/unsupported-document-messages.test.js diff --git a/src/documents/src/engine/errors.js b/src/documents/src/engine/errors.js index 2a865d74f..571c5d343 100644 --- a/src/documents/src/engine/errors.js +++ b/src/documents/src/engine/errors.js @@ -1,5 +1,8 @@ +const NOTICE = /^This document contains ([\s\S]*), which this first editor version cannot edit yet\. The original file has not been changed\.$/; export class UnsupportedDocumentError extends Error { constructor(feature) { + // A reason kept as text (a style or list definition's error) and raised again names its feature once. + for (let match; typeof feature === 'string' && (match = NOTICE.exec(feature));) feature = match[1]; super(`This document contains ${feature}, which this first editor version cannot edit yet. The original file has not been changed.`); this.name = 'UnsupportedDocumentError'; this.feature = feature; diff --git a/src/documents/tests/unsupported-document-messages.test.js b/src/documents/tests/unsupported-document-messages.test.js new file mode 100644 index 000000000..fb068462e --- /dev/null +++ b/src/documents/tests/unsupported-document-messages.test.js @@ -0,0 +1,39 @@ +import { beforeAll, afterAll, it, expect } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate'; +import { DocxPackage } from '../src/engine/docx.js'; +import { UnsupportedDocumentError } from '../src/engine/errors.js'; +import { docxWithParagraphs } from './helpers/docx.js'; + +// A style or list definition that cannot be read keeps its reason as text and reports it again when a +// paragraph uses it. The reason was wrapped a second time, so the read-only notice listed +// "This document contains This document contains …, which this first editor version cannot edit yet. +// The original file has not been changed., which …" instead of the feature. +let dom; +beforeAll(() => { dom = new JSDOM(); globalThis.DOMParser = dom.window.DOMParser; globalThis.XMLSerializer = dom.window.XMLSerializer; }); +afterAll(() => dom.window.close()); + +const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; +function styledPackage() { + const parts = unzipSync(docxWithParagraphs([{ text: 'Styled' }])); + parts['word/document.xml'] = strToU8(strFromU8(parts['word/document.xml']).replace('', '')); + parts['word/styles.xml'] = strToU8(``); + parts['word/_rels/document.xml.rels'] = strToU8(``); + return zipSync(parts); +} + +it('names an unsupported feature of a used style once', async () => { + const error = await DocxPackage.open(styledPackage()).catch(e => e); + expect(error).toBeInstanceOf(UnsupportedDocumentError); + expect(error.feature).toBe('strike and dstrike on the same property set'); + expect(error.message.match(/This document contains/g)).toHaveLength(1); + const preview = await DocxPackage.open(styledPackage(), { bestEffort: true }); + expect(preview.readOnly).toBe(true); + expect(preview.previewIssues).toContain('strike and dstrike on the same property set'); + expect(preview.previewIssues.some(issue => issue.includes('This document contains'))).toBe(false); +}); + +it('keeps a reason that is not already a full notice as it is', () => { + expect(new UnsupportedDocumentError('smartTag').feature).toBe('smartTag'); + expect(new UnsupportedDocumentError(new UnsupportedDocumentError('bdo').message).feature).toBe('bdo'); +}); From 3260937c7a94b435168da6b9d12abc3b4c5eb1b7 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:58:47 -0700 Subject: [PATCH 042/186] Documents: Open documents whose list numbers switch font options off, as Word's legal outlines do, for editing Word writes every font option of a list level's number, switched off where unused (vanish, outline, emphasis mark none ...). Any of them opened the whole document read-only; they state no effect and are now ignored and kept. --- src/documents/docs/conformance.md | 1 + src/documents/src/engine/docx-numbering.js | 8 +++ .../numbering-inert-marker-properties.test.js | 51 +++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 src/documents/tests/numbering-inert-marker-properties.test.js diff --git a/src/documents/docs/conformance.md b/src/documents/docs/conformance.md index 6113b2429..df03cdab0 100644 --- a/src/documents/docs/conformance.md +++ b/src/documents/docs/conformance.md @@ -24397,3 +24397,4 @@ reason in the document itself. Each row has a unit regression that fails without | Password-protected and Word 97–2003 files named .docx | [MS-CFB] §2.2 compound file header signature; [MS-OFFCRYPTO] §2.3.4.4 `EncryptedPackage` stream; [MS-DOC] `WordDocument` stream | A password-protected Word document is an OLE compound file holding the encrypted package, not a ZIP package, and so is a Word 97–2003 document saved with a `.docx` name. Both failed with "Could not read this DOCX archive: invalid zip data". A compound file is now recognized by its signature and the message says what to do: remove the password in Word, or save the file as `.docx` there. Nothing is decrypted or converted. Unit regression `tests/docx-open-errors.test.js`. | | Imported external content in the preview | ECMA-376 Part 1 §17.17.2.1 `altChunk`, §11.3.1 Alternative Format Import part; RFC 2557 MIME HTML, RFC 2045 transfer encodings | html-docx-js and document-assembly tools that merge files write a whole document as a `w:altChunk` naming an HTML page, a MIME HTML archive, plain text or another WordprocessingML package, and leave its conversion to the consumer. Such a document opens read-only (the chunk is not converted), and its preview was an empty page. The read-only preview now shows the chunk's text where the `altChunk` stands, in the body or a table cell: HTML block elements become paragraphs with bold and italic runs (the first `text/html` part of a MIME HTML archive, quoted-printable or base64, with its declared charset), plain text becomes one paragraph per line, and an embedded package shows its body text as the plain-text preview does. Scripts, styles and pictures in the HTML are not shown, nothing is fetched, and saving still keeps the original file. RTF chunks and chunks in headers or footers stay out of the preview. Unit regression `tests/alt-chunk-preview.test.js` (MIME HTML as html-docx-js writes it, a windows-1252 page, text, an embedded package in a table cell, unreadable chunks). | | Imported list number formats | ECMA-376 Part 1 §17.9.17 `numFmt`, §17.18.59 ST_NumberFormat, §17.9.11 `lvlText` | A list level in any number format other than decimal, zero-padded decimal, letters, Roman numerals, bullets and none opened the whole document read-only ("Unsupported numbering format"), which took in most Chinese, Japanese and Korean documents (一、二、 ①②③ 가나다) and legal ones (First, 1st). These formats are now numbered and editable: `decimalHalfWidth`, `decimalFullWidth`, `decimalFullWidth2`, `decimalEnclosedCircle`, `decimalEnclosedCircleChinese` (①–㊿), `decimalEnclosedParen` and `decimalEnclosedFullstop` (1–20), `ordinal`, `cardinalText`, `ordinalText` (English, capitalized words), `chicago`, `chineseCounting` and `japaneseCounting` (below 100,000,000; Chinese writes 零 for skipped places and keeps 一 before 百 and 千, Japanese does neither), `ideographDigital`, `ideographTraditional` (甲–癸), `ideographZodiac` (子–亥), `aiueo` and `aiueoFullWidth` (46 kana), `ganada` and `chosung` (14), and `numberInDash`. §17.18.59 defines these sequences by example only; a value past a sequence's characters is written in decimal digits, an approximation of this editor. The format is kept in `numbering.xml` through edits and save; the list dialogs still offer only their own styles. Other formats (Hebrew, Arabic, Hindi, Thai, Korean counting, iroha, …) and custom `w:format` strings still open read-only. Unit regression `tests/imported-numbering-formats.test.js`; rejection fixtures that used `decimalEnclosedCircle` or `chicago` as their unsupported format now use `hebrew2`. | +| List number formatting switched off | ECMA-376 Part 1 §17.9.24 level `rPr`, §17.3.2 run properties (§17.3.2.41 `vanish`, §17.3.2.23 `outline`, §17.3.2.31 `shadow`, §17.3.2.13 `emboss`, §17.3.2.18 `imprint`, §17.3.2.44 `webHidden`, §17.3.2.21 `noProof`, §17.3.2.12 `em`, §17.3.2.11 `effect`, §17.3.2.15 `highlight`, §17.3.2.4 `bdr`) | Word's Font dialog for a list level writes every option it shows, switched off where unused (``, `` …), as legal and outline numbering templates carry. Any of these made the whole document open read-only ("Unsupported numbering symbol formatting: vanish"). Declarations that state no effect on the number (the on/off properties `vanish`, `outline`, `shadow`, `emboss`, `imprint`, `webHidden`, `specVanish`, `rtl`, `cs`, `oMath` declared off; `em`, `effect` and `highlight` of `none`; a `bdr` of `none` or `nil`; and `noProof`, which affects proofing only) are now ignored and kept in `numbering.xml`. The same properties switched on still open read-only, and `strike` with `dstrike` in one property set keeps its documented rejection. Unit regression `tests/numbering-inert-marker-properties.test.js`. | diff --git a/src/documents/src/engine/docx-numbering.js b/src/documents/src/engine/docx-numbering.js index 889de3f0f..e116afcd2 100644 --- a/src/documents/src/engine/docx-numbering.js +++ b/src/documents/src/engine/docx-numbering.js @@ -36,6 +36,13 @@ const integer = (value, fallback, maximum = 2147483647) => { return Number(value); }; const on = node => node && !['0', 'false', 'off'].includes(val(node)); +// Level run properties that state no effect on the number (§17.3.2): switched off, or none. Word's +// Font dialog for a list level writes each option it shows this way. They are ignored and kept. +const OFF_PROPERTIES = ['vanish', 'outline', 'shadow', 'emboss', 'imprint', 'webHidden', 'specVanish', 'rtl', 'cs', 'oMath']; +const inertMarkerProperty = n => n.namespaceURI === W && (n.localName === 'noProof' + || OFF_PROPERTIES.includes(n.localName) && ['0', 'false', 'off'].includes(val(n)) + || ['em', 'effect', 'highlight'].includes(n.localName) && val(n) === 'none' + || n.localName === 'bdr' && ['none', 'nil'].includes(val(n))); // CT_PPrBase order (Part 4 §A.1) of the properties a numbering level's pPr may carry here. const LEVEL_PPR_ORDER = ['keepNext', 'keepLines', 'pageBreakBefore', 'widowControl', 'suppressLineNumbers', 'pBdr', 'shd', 'tabs', 'suppressAutoHyphens', 'spacing', 'ind', 'contextualSpacing', 'mirrorIndents', 'jc']; const LEVEL_PPR = ['ind', 'tabs', 'contextualSpacing', ...LEVEL_PARAGRAPH_ELEMENTS]; @@ -95,6 +102,7 @@ export function readNumbering(parts, readGeometry, readColor, readFonts, onError const paragraph = pPr && readParagraph ? readParagraph(pPr) : {}; const rPr = child(node, 'rPr'), markerStyle = { ...readFonts?.(rPr) }; for (const n of children(rPr)) { + if (inertMarkerProperty(n)) continue; if (n.namespaceURI !== W || !['rFonts', 'lang', 'b', 'i', 'bCs', 'iCs', 'szCs', 'caps', 'smallCaps', 'strike', 'dstrike', 'color', 'sz', 'u', 'shd', 'vertAlign', 'position', 'spacing', 'w', 'kern'].includes(n.localName)) throw new Error(`Unsupported numbering symbol formatting: ${n.localName}.`); if (['bCs', 'iCs', 'szCs'].includes(n.localName)) { // Private-use glyphs in symbol fonts retain the ordinary run properties; diff --git a/src/documents/tests/numbering-inert-marker-properties.test.js b/src/documents/tests/numbering-inert-marker-properties.test.js new file mode 100644 index 000000000..90fa47765 --- /dev/null +++ b/src/documents/tests/numbering-inert-marker-properties.test.js @@ -0,0 +1,51 @@ +import { beforeAll, afterAll, it, expect } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate'; +import { DocxPackage } from '../src/engine/docx.js'; +import { TextDocument } from '../src/engine/model.js'; +import { listMarkers } from '../src/engine/numbering.js'; + +// A list level's rPr (ECMA-376 Part 1 §17.9.24, CT_RPr §17.3.2) formats its number. Word's +// Define New Multilevel List > Font writes every font option it shows, switched off where unused: +// noProof, vanish, outline, shadow, emboss, imprint, webHidden w:val="0", em w:val="none"… Any of +// them made the whole document open read-only ("Unsupported numbering symbol formatting: vanish"). +// Declared off, they change nothing, so they are now accepted and kept. +let dom; +beforeAll(() => { dom = new JSDOM(); globalThis.DOMParser = dom.window.DOMParser; globalThis.XMLSerializer = dom.window.XMLSerializer; }); +afterAll(() => dom.window.close()); + +const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; +const R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'; +const LEGAL = ''; +function listPackage(rPr) { + const numbering = `${rPr}`; + const item = text => `${text}`; + return zipSync(Object.fromEntries(Object.entries({ + '[Content_Types].xml': ``, + '_rels/.rels': ``, + 'word/_rels/document.xml.rels': ``, + 'word/numbering.xml': numbering, + 'word/document.xml': `${item('Definitions')}${item('Term')}`, + }).map(([name, xml]) => [name, strToU8(xml)]))); +} + +it('opens a list whose number switches every font option off, as Word writes legal outlines, for editing', async () => { + const pkg = await DocxPackage.open(listPackage(LEGAL)); + expect(pkg.readOnly).toBeFalsy(); + expect(listMarkers(pkg.paragraphs, pkg.numbering).map(m => m.text)).toEqual(['Article 1.', 'Article 2.']); + const style = pkg.numbering.abstracts['0'].levels[0].markerStyle; + expect(style.bold).toBe(false); expect(style.fontSize).toBe(12); + // An edit keeps the level as authored. + const doc = new TextDocument(pkg.paragraphs, pkg.pageSettings, pkg.numbering, pkg.styles, pkg.tables, pkg.assets); + doc.select(doc.text.length); doc.insertParagraph(); doc.replace('Scope'); + const parts = unzipSync(new Uint8Array(await pkg.export(doc.paragraphs, doc.pageSettings, doc.numbering, doc.styles, doc.tables, doc.assets).arrayBuffer())); + expect(strFromU8(parts['word/numbering.xml'])).toContain(''); + const reopened = await DocxPackage.open(zipSync(parts)); + expect(listMarkers(reopened.paragraphs, reopened.numbering).map(m => m.text)).toEqual(['Article 1.', 'Article 2.', 'Article 3.']); +}); + +it('still opens a number that is hidden, outlined or emphasized read-only', async () => { + for (const rPr of ['', '', '', '', '']) { + await expect(DocxPackage.open(listPackage(rPr)), rPr).rejects.toThrow('Unsupported numbering symbol formatting'); + } +}); From dae6171500dc8af5eb3c7090416d31a2d1efc3b0 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:06:07 -0700 Subject: [PATCH 043/186] Documents: Use the title and author set in File > Info in property fields and Quick Parts Property fields and Document Property read docProps/core.xml as the file was opened, so properties changed in this session were ignored until reopening, and a core properties part stored under another name was never found. --- src/documents/docs/conformance.md | 1 + src/documents/src/engine/field-results.js | 17 +++++--- src/documents/src/views/insert-tab.js | 10 ++--- .../browser/document-property-fields.spec.js | 26 ++++++++++++ .../tests/document-property-fields.test.js | 40 +++++++++++++++++++ src/documents/tests/field-results.test.js | 4 +- 6 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 src/documents/tests/browser/document-property-fields.spec.js create mode 100644 src/documents/tests/document-property-fields.test.js diff --git a/src/documents/docs/conformance.md b/src/documents/docs/conformance.md index df03cdab0..e80f2c33d 100644 --- a/src/documents/docs/conformance.md +++ b/src/documents/docs/conformance.md @@ -24398,3 +24398,4 @@ reason in the document itself. Each row has a unit regression that fails without | Imported external content in the preview | ECMA-376 Part 1 §17.17.2.1 `altChunk`, §11.3.1 Alternative Format Import part; RFC 2557 MIME HTML, RFC 2045 transfer encodings | html-docx-js and document-assembly tools that merge files write a whole document as a `w:altChunk` naming an HTML page, a MIME HTML archive, plain text or another WordprocessingML package, and leave its conversion to the consumer. Such a document opens read-only (the chunk is not converted), and its preview was an empty page. The read-only preview now shows the chunk's text where the `altChunk` stands, in the body or a table cell: HTML block elements become paragraphs with bold and italic runs (the first `text/html` part of a MIME HTML archive, quoted-printable or base64, with its declared charset), plain text becomes one paragraph per line, and an embedded package shows its body text as the plain-text preview does. Scripts, styles and pictures in the HTML are not shown, nothing is fetched, and saving still keeps the original file. RTF chunks and chunks in headers or footers stay out of the preview. Unit regression `tests/alt-chunk-preview.test.js` (MIME HTML as html-docx-js writes it, a windows-1252 page, text, an embedded package in a table cell, unreadable chunks). | | Imported list number formats | ECMA-376 Part 1 §17.9.17 `numFmt`, §17.18.59 ST_NumberFormat, §17.9.11 `lvlText` | A list level in any number format other than decimal, zero-padded decimal, letters, Roman numerals, bullets and none opened the whole document read-only ("Unsupported numbering format"), which took in most Chinese, Japanese and Korean documents (一、二、 ①②③ 가나다) and legal ones (First, 1st). These formats are now numbered and editable: `decimalHalfWidth`, `decimalFullWidth`, `decimalFullWidth2`, `decimalEnclosedCircle`, `decimalEnclosedCircleChinese` (①–㊿), `decimalEnclosedParen` and `decimalEnclosedFullstop` (1–20), `ordinal`, `cardinalText`, `ordinalText` (English, capitalized words), `chicago`, `chineseCounting` and `japaneseCounting` (below 100,000,000; Chinese writes 零 for skipped places and keeps 一 before 百 and 千, Japanese does neither), `ideographDigital`, `ideographTraditional` (甲–癸), `ideographZodiac` (子–亥), `aiueo` and `aiueoFullWidth` (46 kana), `ganada` and `chosung` (14), and `numberInDash`. §17.18.59 defines these sequences by example only; a value past a sequence's characters is written in decimal digits, an approximation of this editor. The format is kept in `numbering.xml` through edits and save; the list dialogs still offer only their own styles. Other formats (Hebrew, Arabic, Hindi, Thai, Korean counting, iroha, …) and custom `w:format` strings still open read-only. Unit regression `tests/imported-numbering-formats.test.js`; rejection fixtures that used `decimalEnclosedCircle` or `chicago` as their unsupported format now use `hebrew2`. | | List number formatting switched off | ECMA-376 Part 1 §17.9.24 level `rPr`, §17.3.2 run properties (§17.3.2.41 `vanish`, §17.3.2.23 `outline`, §17.3.2.31 `shadow`, §17.3.2.13 `emboss`, §17.3.2.18 `imprint`, §17.3.2.44 `webHidden`, §17.3.2.21 `noProof`, §17.3.2.12 `em`, §17.3.2.11 `effect`, §17.3.2.15 `highlight`, §17.3.2.4 `bdr`) | Word's Font dialog for a list level writes every option it shows, switched off where unused (``, `` …), as legal and outline numbering templates carry. Any of these made the whole document open read-only ("Unsupported numbering symbol formatting: vanish"). Declarations that state no effect on the number (the on/off properties `vanish`, `outline`, `shadow`, `emboss`, `imprint`, `webHidden`, `specVanish`, `rtl`, `cs`, `oMath` declared off; `em`, `effect` and `highlight` of `none`; a `bdr` of `none` or `nil`; and `noProof`, which affects proofing only) are now ignored and kept in `numbering.xml`. The same properties switched on still open read-only, and `strike` with `dstrike` in one property set keeps its documented rejection. Unit regression `tests/numbering-inert-marker-properties.test.js`. | +| Document properties in fields | ECMA-376 Part 2 §11 core properties (relationship-named part, §11.1 elements); Part 1 §17.16.5 `TITLE`, `AUTHOR`, `SUBJECT`, `KEYWORDS`, `COMMENTS`, `LASTSAVEDBY`, `DOCPROPERTY` | Fields that show a document property, Quick Parts > Document Property and the cover-page title read the properties from `docProps/core.xml` as the file was opened. A title or author set in File > Info was ignored until the document was saved and reopened, and a package whose core properties part the package relationship names elsewhere (System.IO.Packaging writes `package/services/metadata/core-properties/.psmdcp`) showed none. They now read the part the relationship names, with this session's edits over it. Extended properties (company, manager, template) are still read from `docProps/app.xml`. Unit regression `tests/document-property-fields.test.js`; browser regression `tests/browser/document-property-fields.spec.js`. | diff --git a/src/documents/src/engine/field-results.js b/src/documents/src/engine/field-results.js index cf753bad9..dc202d920 100644 --- a/src/documents/src/engine/field-results.js +++ b/src/documents/src/engine/field-results.js @@ -10,6 +10,7 @@ import { newBodyField } from './toc.js'; import { bodyFieldRanges, newFieldEndOrder } from './body-fields.js'; import { sharedRevision } from './revisions.js'; import { countWords } from '../core/text.js'; +import { readCoreProperties } from './docx-core-properties.js'; const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; @@ -72,15 +73,21 @@ export function applyGeneralFormat(text, format) { return text; } -/** Core and extended document properties from a package's parts: { title, subject, author, keywords, comments, lastModifiedBy, category, created, modified, company, manager, … }. */ -export function documentProperties(parts = {}) { +/** + * Core and extended document properties from a package's parts: { title, subject, author, keywords, comments, lastModifiedBy, category, created, modified, company, manager, … }. + * Core properties come from the part the package relationship names (Part 2 §11); `core`, when given, holds + * them with this session's edits (DocxPackage#coreProperties). + */ +export function documentProperties(parts = {}, core = readCoreProperties(parts)) { const read = path => { const bytes = parts[path]; if (!bytes) return null; try { return new DOMParser().parseFromString(typeof bytes === 'string' ? bytes : new TextDecoder().decode(bytes), 'application/xml'); } catch { return null; } }; const text = (doc, name) => [...(doc?.documentElement?.children || [])].find(n => n.localName === name)?.textContent?.trim() || ''; - const core = read('docProps/core.xml'), app = read('docProps/app.xml'); - return { title: text(core, 'title'), subject: text(core, 'subject'), author: text(core, 'creator'), keywords: text(core, 'keywords'), comments: text(core, 'description'), - lastModifiedBy: text(core, 'lastModifiedBy'), category: text(core, 'category'), status: text(core, 'contentStatus'), created: text(core, 'created'), modified: text(core, 'modified'), + const app = read('docProps/app.xml'); + return { title: core.title || '', subject: core.subject || '', author: core.creator || '', keywords: core.keywords || '', comments: core.description || '', + lastModifiedBy: core.lastModifiedBy || '', category: core.category || '', status: core.contentStatus || '', created: core.created || '', modified: core.modified || '', company: text(app, 'Company'), manager: text(app, 'Manager'), template: text(app, 'Template') }; } +/** The document properties of an open package, including properties changed in File > Properties since it was opened. */ +export const packageDocumentProperties = pkg => documentProperties(pkg?.parts || {}, pkg?.coreProperties); /** * The fields the Field dialog offers: name, category, a short description in this app's words, the diff --git a/src/documents/src/views/insert-tab.js b/src/documents/src/views/insert-tab.js index f8b3e4ca9..f75ff0abd 100644 --- a/src/documents/src/views/insert-tab.js +++ b/src/documents/src/views/insert-tab.js @@ -23,7 +23,7 @@ import { readDrawingFragment, placeDrawing, freshDrawingAssetId, nextDrawingId, import { pictureFragment, fittedSize } from '../engine/picture-insert.js'; import { imageInfo, MAX_IMAGE_SIDE, MAX_IMAGE_PIXELS } from '../engine/images.js'; import { diagramDrawingXml, parseDiagramDescription, DIAGRAM_LAYOUTS } from '../engine/diagram-builder.js'; -import { insertBodyField, fieldInstruction, evaluateField, documentProperties, setFieldContextProvider, DOCUMENT_PROPERTY_NAMES, formatDatePicture } from '../engine/field-results.js'; +import { insertBodyField, fieldInstruction, evaluateField, packageDocumentProperties, setFieldContextProvider, DOCUMENT_PROPERTY_NAMES, formatDatePicture } from '../engine/field-results.js'; import { linearToOmml, ommlToLinear, ommlToEditableLinear, EQUATION_GALLERY } from '../engine/math-linear.js'; import { ommlToMathml } from '../engine/omml-mathml.js'; import { dropCapState, setDropCap } from '../engine/drop-caps.js'; @@ -83,13 +83,13 @@ export function createInsert({ editor, pkg, error, base = {}, docName = () => '' const twips = box ? box.width * 15 - context.cell.margins.left - context.cell.margins.right : p.width - p.left - p.right - (p.gutterAtTop ? 0 : p.gutter || 0); return Math.max(box ? EMU_PER_INCH / 2 : EMU_PER_INCH, twips * TWIP_EMU); }; - const title = () => documentProperties(pkg()?.parts || {}).title || String(docName() || '').replace(/\.docx$/i, '') || 'Document Title'; + const title = () => packageDocumentProperties(pkg()).title || String(docName() || '').replace(/\.docx$/i, '') || 'Document Title'; const today = () => new Intl.DateTimeFormat(undefined, { dateStyle: 'long' }).format(new Date()); /** Run an editor transaction, reporting failures; returns whether it applied. */ const run = (engine, action) => { let failure = null; const ok = engine.execute(action, e => { failure = e; }); if (failure) fail(failure); engine.focus(); if (ok) onChange(); return ok; }; const fieldContext = () => { const engine = editor(); - return { now: new Date(), properties: documentProperties(pkg()?.parts || {}), fileName: /\.\w+$/.test(docName() || '') ? docName() : `${docName() || 'Document'}.docx`, user: { name: user() || '' }, + return { now: new Date(), properties: packageDocumentProperties(pkg()), fileName: /\.\w+$/.test(docName() || '') ? docName() : `${docName() || 'Document'}.docx`, user: { name: user() || '' }, pages: engine?.layout?.pageCount, page: (engine?.caretPageIndex ?? 0) + 1 }; }; setFieldContextProvider(fieldContext); @@ -523,7 +523,7 @@ export function createInsert({ editor, pkg, error, base = {}, docName = () => '' } }); } const propertyField = (label, key) => { - const value = documentProperties(pkg()?.parts || {})[key]; + const value = packageDocumentProperties(pkg())[key]; insertFieldInstruction(fieldInstruction({ name: 'DOCPROPERTY', argument: label }), value || `[${label}]`); }; const autoTextKey = () => `${AUTOTEXT_KEY}.${user() || 'local'}`; @@ -558,7 +558,7 @@ export function createInsert({ editor, pkg, error, base = {}, docName = () => '' ...(entries.length ? [{ label: 'Delete Entry', items: entries.map(entry => ({ label: entry.name, onSelect: async () => { if (await confirmDialog({ title: 'Delete AutoText?', message: `Delete “${entry.name}” from your AutoText gallery?`, confirmLabel: 'Delete' })) saveAutoTexts(autoTexts().filter(e => e.name !== entry.name)); } })) }] : [])] }, - { label: 'Document Property', disabled: !engine, items: DOCUMENT_PROPERTY_NAMES.map(([label, key]) => ({ label: label === 'LastSavedBy' ? 'Last Saved By' : label, hint: documentProperties(pkg()?.parts || {})[key] || 'Empty', onSelect: () => propertyField(label, key) })) }, + { label: 'Document Property', disabled: !engine, items: DOCUMENT_PROPERTY_NAMES.map(([label, key]) => ({ label: label === 'LastSavedBy' ? 'Last Saved By' : label, hint: packageDocumentProperties(pkg())[key] || 'Empty', onSelect: () => propertyField(label, key) })) }, { label: 'Field…', icon: 'field', disabled: !engine, onSelect: insertField }, ]; } diff --git a/src/documents/tests/browser/document-property-fields.spec.js b/src/documents/tests/browser/document-property-fields.spec.js new file mode 100644 index 000000000..88a3db399 --- /dev/null +++ b/src/documents/tests/browser/document-property-fields.spec.js @@ -0,0 +1,26 @@ +import { test, expect } from '@playwright/test'; +import { onTab } from './helpers/editor-tools.js'; + +// A title set in File > Info is the document's title from then on: Quick Parts > Document Property +// inserts it, as TITLE fields show it, without saving and reopening the document first. +test('a title set in the File view is the one Document Property inserts', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/mock.html#/new'); + await expect(page.getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + const input = page.getByRole('textbox', { name: 'Document text' }); + await input.fill('Report: '); + await page.locator('.ribbon-file').click(); + const view = page.getByRole('dialog', { name: 'File' }); + await view.getByRole('textbox', { name: 'Title', exact: true }).fill('Harbour Report'); + await view.getByRole('button', { name: 'Save Properties' }).click(); + await expect(view.getByRole('status')).toContainText('Properties updated'); + await page.keyboard.press('Escape'); + await expect(view).toBeHidden(); + await input.press('ControlOrMeta+End'); + await onTab(page, 'insert'); + await page.locator('#ribbon-panel-insert').getByRole('button', { name: 'Quick Parts', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Document Property' }).click(); + await expect(page.getByRole('menuitem', { name: /^Title/ })).toContainText('Harbour Report'); + await page.getByRole('menuitem', { name: /^Title/ }).click(); + await expect.poll(() => input.inputValue()).toBe('Report: Harbour Report'); +}); diff --git a/src/documents/tests/document-property-fields.test.js b/src/documents/tests/document-property-fields.test.js new file mode 100644 index 000000000..98488ba91 --- /dev/null +++ b/src/documents/tests/document-property-fields.test.js @@ -0,0 +1,40 @@ +import { beforeAll, afterAll, it, expect } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate'; +import { DocxPackage } from '../src/engine/docx.js'; +import { evaluateField, packageDocumentProperties } from '../src/engine/field-results.js'; +import { docxWithParagraphs } from './helpers/docx.js'; + +// TITLE, AUTHOR and DOCPROPERTY fields and Insert > Quick Parts > Document Property read the +// document's core properties (ECMA-376 Part 2 §11). They read docProps/core.xml as the file was +// opened, so a title set in File > Properties was not used until the document was reopened, and a +// package whose core properties part has another name (System.IO.Packaging writes +// package/services/metadata/core-properties/.psmdcp) showed none at all. +let dom; +beforeAll(() => { dom = new JSDOM(); globalThis.DOMParser = dom.window.DOMParser; globalThis.XMLSerializer = dom.window.XMLSerializer; }); +afterAll(() => dom.window.close()); + +const CORE = 'http://schemas.openxmlformats.org/package/2006/metadata/core-properties'; +function withCore(path) { + const parts = unzipSync(docxWithParagraphs([{ text: 'Body' }])); + parts[path] = strToU8(`Old titleAda`); + parts['_rels/.rels'] = strToU8(strFromU8(parts['_rels/.rels']).replace('', ``)); + parts['[Content_Types].xml'] = strToU8(strFromU8(parts['[Content_Types].xml']).replace('', ``)); + return zipSync(parts); +} +const field = (instruction, pkg) => ({ text: evaluateField(instruction, { now: new Date(2026, 0, 2), properties: packageDocumentProperties(pkg) }) }); + +it('uses properties changed in this session in title and property fields', async () => { + const pkg = await DocxPackage.open(withCore('docProps/core.xml')); + expect(field('TITLE', pkg).text).toBe('Old title'); + pkg.setCoreProperties({ title: 'Quarterly report', creator: 'Grace' }); + expect(field('TITLE', pkg).text).toBe('Quarterly report'); + expect(field('AUTHOR', pkg).text).toBe('Grace'); + expect(packageDocumentProperties(pkg).title).toBe('Quarterly report'); +}); + +it('reads the core properties part the package relationship names', async () => { + const pkg = await DocxPackage.open(withCore('package/services/metadata/core-properties/4f2c.psmdcp')); + expect(field('TITLE', pkg).text).toBe('Old title'); + expect(packageDocumentProperties(pkg).author).toBe('Ada'); +}); diff --git a/src/documents/tests/field-results.test.js b/src/documents/tests/field-results.test.js index 32f13c7c3..343954d47 100644 --- a/src/documents/tests/field-results.test.js +++ b/src/documents/tests/field-results.test.js @@ -66,7 +66,9 @@ describe('field evaluation', () => { expect(fieldInstruction({ name: 'DOCPROPERTY', argument: 'Company', format: 'Upper', preserve: false })).toBe(' DOCPROPERTY Company \\* Upper '); }); it('reads core and extended properties', () => { - const parts = { 'docProps/core.xml': strToU8('TA'), 'docProps/app.xml': strToU8('C') }; + const parts = { 'docProps/core.xml': strToU8('TA'), 'docProps/app.xml': strToU8('C') , + // Core properties are the part the package relationship names (Part 2 §11). + '_rels/.rels': strToU8('') }; expect(documentProperties(parts)).toMatchObject({ title: 'T', author: 'A', company: 'C' }); }); }); From 95281a4f91be5a16be1ab6f36ab7b6cf45b27d00 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:07:25 -0700 Subject: [PATCH 044/186] Documents: Keep one core properties relationship when setting properties of a file that lost its properties part A relationship left without its part got a second one beside it, which makes the saved package invalid; the part is now created where it points. --- src/documents/docs/conformance.md | 1 + .../src/engine/docx-core-properties.js | 27 +++++++++++++---- .../core-properties-missing-part.test.js | 30 +++++++++++++++++++ 3 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 src/documents/tests/core-properties-missing-part.test.js diff --git a/src/documents/docs/conformance.md b/src/documents/docs/conformance.md index e80f2c33d..5381d3e2d 100644 --- a/src/documents/docs/conformance.md +++ b/src/documents/docs/conformance.md @@ -24399,3 +24399,4 @@ reason in the document itself. Each row has a unit regression that fails without | Imported list number formats | ECMA-376 Part 1 §17.9.17 `numFmt`, §17.18.59 ST_NumberFormat, §17.9.11 `lvlText` | A list level in any number format other than decimal, zero-padded decimal, letters, Roman numerals, bullets and none opened the whole document read-only ("Unsupported numbering format"), which took in most Chinese, Japanese and Korean documents (一、二、 ①②③ 가나다) and legal ones (First, 1st). These formats are now numbered and editable: `decimalHalfWidth`, `decimalFullWidth`, `decimalFullWidth2`, `decimalEnclosedCircle`, `decimalEnclosedCircleChinese` (①–㊿), `decimalEnclosedParen` and `decimalEnclosedFullstop` (1–20), `ordinal`, `cardinalText`, `ordinalText` (English, capitalized words), `chicago`, `chineseCounting` and `japaneseCounting` (below 100,000,000; Chinese writes 零 for skipped places and keeps 一 before 百 and 千, Japanese does neither), `ideographDigital`, `ideographTraditional` (甲–癸), `ideographZodiac` (子–亥), `aiueo` and `aiueoFullWidth` (46 kana), `ganada` and `chosung` (14), and `numberInDash`. §17.18.59 defines these sequences by example only; a value past a sequence's characters is written in decimal digits, an approximation of this editor. The format is kept in `numbering.xml` through edits and save; the list dialogs still offer only their own styles. Other formats (Hebrew, Arabic, Hindi, Thai, Korean counting, iroha, …) and custom `w:format` strings still open read-only. Unit regression `tests/imported-numbering-formats.test.js`; rejection fixtures that used `decimalEnclosedCircle` or `chicago` as their unsupported format now use `hebrew2`. | | List number formatting switched off | ECMA-376 Part 1 §17.9.24 level `rPr`, §17.3.2 run properties (§17.3.2.41 `vanish`, §17.3.2.23 `outline`, §17.3.2.31 `shadow`, §17.3.2.13 `emboss`, §17.3.2.18 `imprint`, §17.3.2.44 `webHidden`, §17.3.2.21 `noProof`, §17.3.2.12 `em`, §17.3.2.11 `effect`, §17.3.2.15 `highlight`, §17.3.2.4 `bdr`) | Word's Font dialog for a list level writes every option it shows, switched off where unused (``, `` …), as legal and outline numbering templates carry. Any of these made the whole document open read-only ("Unsupported numbering symbol formatting: vanish"). Declarations that state no effect on the number (the on/off properties `vanish`, `outline`, `shadow`, `emboss`, `imprint`, `webHidden`, `specVanish`, `rtl`, `cs`, `oMath` declared off; `em`, `effect` and `highlight` of `none`; a `bdr` of `none` or `nil`; and `noProof`, which affects proofing only) are now ignored and kept in `numbering.xml`. The same properties switched on still open read-only, and `strike` with `dstrike` in one property set keeps its documented rejection. Unit regression `tests/numbering-inert-marker-properties.test.js`. | | Document properties in fields | ECMA-376 Part 2 §11 core properties (relationship-named part, §11.1 elements); Part 1 §17.16.5 `TITLE`, `AUTHOR`, `SUBJECT`, `KEYWORDS`, `COMMENTS`, `LASTSAVEDBY`, `DOCPROPERTY` | Fields that show a document property, Quick Parts > Document Property and the cover-page title read the properties from `docProps/core.xml` as the file was opened. A title or author set in File > Info was ignored until the document was saved and reopened, and a package whose core properties part the package relationship names elsewhere (System.IO.Packaging writes `package/services/metadata/core-properties/.psmdcp`) showed none. They now read the part the relationship names, with this session's edits over it. Extended properties (company, manager, template) are still read from `docProps/app.xml`. Unit regression `tests/document-property-fields.test.js`; browser regression `tests/browser/document-property-fields.spec.js`. | +| Core properties relationship without its part | ECMA-376 Part 2 §8.3 (a package has at most one core properties relationship), §11 core properties part, §10.1.2 content types | A package whose core properties relationship names a part it does not contain (metadata scrubbers delete `docProps/core.xml` and leave the relationship) got a second core properties relationship when a property was set, so the saved package was invalid. The part is now created at the existing relationship's target, with its content type entry. Unit regression `tests/core-properties-missing-part.test.js`. | diff --git a/src/documents/src/engine/docx-core-properties.js b/src/documents/src/engine/docx-core-properties.js index e983b53b3..7ef7ca2d9 100644 --- a/src/documents/src/engine/docx-core-properties.js +++ b/src/documents/src/engine/docx-core-properties.js @@ -40,16 +40,22 @@ const MAX_LENGTH = 4096; const serialize = node => new XMLSerializer().serializeToString(node); const decode = bytes => bytes instanceof Uint8Array ? new TextDecoder().decode(bytes) : String(bytes); -/** The core-properties part named by the package relationships, or null. */ -export function corePropertiesPath(parts) { +// The part name the package's core-properties relationship targets, whether or not the part exists, or null. +function coreTarget(parts) { if (!parts['_rels/.rels']) return null; let rels; try { rels = parseXml(parts['_rels/.rels']); } catch { return null; } const link = [...rels.documentElement.children].find(n => n.localName === 'Relationship' && n.getAttribute('Type') === CORE_REL && n.getAttribute('TargetMode') !== 'External'); const target = link?.getAttribute('Target'); if (!target || /[\\?#:]/.test(target)) return null; - const path = target.replace(/^\//, '').split('/').filter(s => s && s !== '.').join('/'); - return Object.hasOwn(parts, path) ? path : null; + const path = target.replace(/^\//, '').split('/').filter(s => s && s !== '.'); + return path.length && !path.includes('..') ? path.join('/') : null; +} + +/** The core-properties part named by the package relationships, or null. */ +export function corePropertiesPath(parts) { + const path = coreTarget(parts); + return path && Object.hasOwn(parts, path) ? path : null; } /** { title, subject, creator, keywords, … } as strings; properties the part lacks are ''. */ @@ -112,9 +118,19 @@ export function writeCoreProperties(parts, patch) { } function createCorePart(parts) { + const empty = strToU8(`\n`); + const types = parseXml(parts['[Content_Types].xml']); + // A package holds one core-properties relationship (Part 2 §8.3): one left without its part gets the part. + const dangling = coreTarget(parts); + if (dangling) { + parts[dangling] = empty; + setContentTypeOverride(types, `/${dangling}`, CORE_TYPE); + parts['[Content_Types].xml'] = strToU8(serialize(types)); + return dangling; + } let path = 'docProps/core.xml'; for (let n = 2; Object.hasOwn(parts, path); n++) path = `docProps/core${n}.xml`; - parts[path] = strToU8(`\n`); + parts[path] = empty; const rels = parseXml(parts['_rels/.rels']); const ids = new Set([...rels.documentElement.children].map(n => n.getAttribute('Id'))); let id = 'rIdCore'; for (let n = 2; ids.has(id); n++) id = `rIdCore${n}`; @@ -122,7 +138,6 @@ function createCorePart(parts) { rel.setAttribute('Id', id); rel.setAttribute('Type', CORE_REL); rel.setAttribute('Target', path); rels.documentElement.append(rel); parts['_rels/.rels'] = strToU8(serialize(rels)); - const types = parseXml(parts['[Content_Types].xml']); setContentTypeOverride(types, `/${path}`, CORE_TYPE); parts['[Content_Types].xml'] = strToU8(serialize(types)); return path; diff --git a/src/documents/tests/core-properties-missing-part.test.js b/src/documents/tests/core-properties-missing-part.test.js new file mode 100644 index 000000000..cb8c60553 --- /dev/null +++ b/src/documents/tests/core-properties-missing-part.test.js @@ -0,0 +1,30 @@ +import { beforeAll, afterAll, it, expect } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate'; +import { DocxPackage } from '../src/engine/docx.js'; +import { TextDocument } from '../src/engine/model.js'; +import { docxWithParagraphs } from './helpers/docx.js'; + +// A package has at most one core properties relationship (ECMA-376 Part 2 §8.3, M4.1: a package with +// more than one is invalid). Metadata scrubbers can delete docProps/core.xml and leave its +// relationship behind. Setting a property then added a second core properties relationship beside +// the dangling one, so the saved file was an invalid package. The part is now created where the +// existing relationship points. +let dom; +beforeAll(() => { dom = new JSDOM(); globalThis.DOMParser = dom.window.DOMParser; globalThis.XMLSerializer = dom.window.XMLSerializer; }); +afterAll(() => dom.window.close()); + +const CORE_REL = 'http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties'; +it('creates a missing core properties part at the target of its existing relationship', async () => { + const parts = unzipSync(docxWithParagraphs([{ text: 'Body' }])); + parts['_rels/.rels'] = strToU8(strFromU8(parts['_rels/.rels']).replace('', ``)); + const pkg = await DocxPackage.open(zipSync(parts)); + expect(pkg.setCoreProperties({ title: 'Kept' })).toBe(true); + const doc = new TextDocument(pkg.paragraphs, pkg.pageSettings, pkg.numbering, pkg.styles, pkg.tables, pkg.assets); + const saved = unzipSync(new Uint8Array(await pkg.export(doc.paragraphs, doc.pageSettings, doc.numbering, doc.styles, doc.tables, doc.assets).arrayBuffer())); + const rels = strFromU8(saved['_rels/.rels']); + expect(rels.match(new RegExp(CORE_REL.replace(/[/.]/g, '\\$&'), 'g'))).toHaveLength(1); + expect(strFromU8(saved['docProps/core.xml'])).toContain('Kept'); + expect(strFromU8(saved['[Content_Types].xml'])).toContain(''); + expect((await DocxPackage.open(zipSync(saved))).coreProperties.title).toBe('Kept'); +}); From 22b4e481a76477acd88809084bc691273a9b0b0d Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:19:32 -0700 Subject: [PATCH 045/186] Documents: Wrap lines after em dashes, en dashes and zero-width spaces A dash or zero-width space was no break opportunity, so text joined by em dashes or a long path with zero-width spaces filled the line and was split in the middle of the next word. --- src/documents/docs/conformance.md | 8 +++++ src/documents/src/engine/dictionary-breaks.js | 21 ++++++++++-- .../tests/dash-line-breaking.test.js | 33 +++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 src/documents/tests/dash-line-breaking.test.js diff --git a/src/documents/docs/conformance.md b/src/documents/docs/conformance.md index 5381d3e2d..ccbad8ff6 100644 --- a/src/documents/docs/conformance.md +++ b/src/documents/docs/conformance.md @@ -7239,6 +7239,14 @@ Automatic linguistic hyphenation, soft-hyphen support and general multilingual line-breaking rules remain separate gaps. Forced grapheme wrapping of oversized unbreakable content retains its existing behavior. +Em dashes (U+2014), en dashes (U+2013) and zero-width spaces (U+200B) also +provide a wrapping opportunity after the character (UAX #14 classes B2, BA and +ZW), in paragraph layout and AutoFit minimum widths alike, so "word—word" and +paths with zero-width spaces wrap there instead of being split mid-word. A +doubled dash stays together, and a dash keeps closing punctuation that follows +it on its line. `tests/dash-line-breaking.test.js` covers these cases; no native +reference rendering was recorded for them. + ## Thai, Lao, Khmer and Myanmar word wrapping ECMA-376 Part 1 §17.3.3.31 (`t`) retains literal authored text and does not diff --git a/src/documents/src/engine/dictionary-breaks.js b/src/documents/src/engine/dictionary-breaks.js index a171dceb5..2aa54edae 100644 --- a/src/documents/src/engine/dictionary-breaks.js +++ b/src/documents/src/engine/dictionary-breaks.js @@ -19,6 +19,19 @@ const IDEOGRAPHIC = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Scr const NO_START = new Set('"\'-‐‑–!%),.:;?]}¢°·’”‰′″℃∶、。〃〆〉》」』】〕〗〙〞〟ぁぃぅぇぉっゃゅょゎゕゖ゛゜ゝゞ゠ァィゥェォッャュョヮヵヶ・ーヽヾㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿ々〻‥…﹚﹜!"%'),.:;?]}~。」、・ァィゥェォャュョッー゙゚¢'); const NO_END = new Set('"\'$([{£¥·‘“〈《「『【〔〖〘〝﹙﹛$([{「£¥'); const OPEN_SPACE = /[\s\ufffc]/u; +// Em and en dashes and the zero-width space allow a line to end after them in any script (UAX #14 +// classes B2, BA and ZW), so a phrase joined by em dashes or a long path with zero-width spaces +// wraps there rather than mid-word. A doubled dash stays together, and a dash keeps the closing +// punctuation that follows it. U+2010 compounds stay together, as the reference controls under +// tests/fixtures/hyphen-wrap show. +const BREAK_AFTER = /[\u200b\u2013\u2014]/u; +function dashBreaks(text, breaks) { + for (let index = 0; index < text.length - 1; index++) { + const c = text[index], next = text[index + 1]; + if (!BREAK_AFTER.test(c) || OPEN_SPACE.test(next) || c !== '\u200b' && (next === c || NO_START.has(next))) continue; + breaks.add(index + 1); + } +} const graphemes = typeof Intl?.Segmenter === 'function' ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null; function* codePoints(text) { let index = 0; for (const segment of text) { yield { segment, index }; index += segment.length; } } function ideographicBreaks(text, breaks) { @@ -34,14 +47,16 @@ function ideographicBreaks(text, breaks) { /** * Offsets in `text` where a line may break besides spaces and hyphens: between dictionary-segmented - * Thai, Lao, Khmer or Myanmar words, and beside ideographs and kana. Null when there are none. + * Thai, Lao, Khmer or Myanmar words, beside ideographs and kana, and after dashes and zero-width + * spaces. Null when there are none. */ export function dictionaryBreaks(text) { - const complex = !!words && COMPLEX.test(text), ideographic = IDEOGRAPHIC.test(text); - if (!complex && !ideographic) return null; + const complex = !!words && COMPLEX.test(text), ideographic = IDEOGRAPHIC.test(text), dashes = BREAK_AFTER.test(text); + if (!complex && !ideographic && !dashes) return null; const breaks = new Set(); if (complex) complexWordBreaks(text, breaks); if (ideographic) ideographicBreaks(text, breaks); + if (dashes) dashBreaks(text, breaks); return breaks.size ? breaks : null; } diff --git a/src/documents/tests/dash-line-breaking.test.js b/src/documents/tests/dash-line-breaking.test.js new file mode 100644 index 000000000..65a867c95 --- /dev/null +++ b/src/documents/tests/dash-line-breaking.test.js @@ -0,0 +1,33 @@ +import { it, expect } from 'vitest'; +import { paragraph } from '../src/engine/model.js'; +import { layoutDocument } from '../src/engine/layout.js'; +import { cellIntrinsicWidths } from '../src/engine/intrinsic-widths.js'; + +const measure = text => ({ width: [...text].length * 8, ascent: 15, descent: 5 }); +// `width` is the writing width in characters; the page adds a 10 px margin on each side. +const lines = (text, width) => layoutDocument([paragraph(text, { format: { spaceAfter: 0 } })], measure, { width: width * 8 + 20, height: 4000, margin: 10 }) + .lines.map(l => text.slice(l.start, l.end)); + +it('wraps after an em dash or en dash instead of splitting the next word', () => { + // Before, the dash was no break opportunity: the line was filled and "cccc" lost a letter. + expect(lines('aaaaaaaaaa—bbbbbbbbbb—cccc', 25)).toEqual(['aaaaaaaaaa—bbbbbbbbbb—', 'cccc']); + expect(lines('pages 10–20 and 30–40 apply', 14)).toEqual(['pages 10–20 ', 'and 30–40 ', 'apply']); + expect(lines('pages 10–20', 10)).toEqual(['pages 10–', '20']); +}); + +it('wraps at a zero-width space, as authors insert into long paths and addresses', () => { + const path = 'https://example.com/​documents/​reports/​annual'; + expect(lines(path, 34)).toEqual(['https://example.com/​documents/​', 'reports/​annual']); +}); + +it('keeps a doubled dash and the punctuation after a dash on the line', () => { + expect(lines('x aa——b', 5)).toEqual(['x ', 'aa——b']); + expect(lines('x aaaa—” b', 7)).toEqual(['x ', 'aaaa—” ', 'b']); + expect(lines('x aaaa—, b', 7)).toEqual(['x ', 'aaaa—, ', 'b']); +}); + +it('sizes AutoFit cells to the widest part between dashes', () => { + const page = { width: 500, height: 500, left: 0, right: 0, top: 0, bottom: 0 }; + const metrics = text => cellIntrinsicWidths({ margins: { left: 0, right: 0 }, content: [{ paragraph: paragraph('', { format: { spaceAfter: 0 }, runs: [{ text }] }), index: 0 }] }, measure, page, undefined, []); + expect(metrics('long—er—word')).toMatchObject({ min: 40, max: 96 }); +}); From 35060810399db8faff3bec0ecdceda83e12a4522 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:41:11 -0700 Subject: [PATCH 046/186] Documents: Justify Chinese and Japanese lines flush to the margin A justified line widened only its spaces, so lines of Chinese or Japanese, which have none, stayed ragged at the right margin. The gaps where such a line could break beside an ideograph or kana are now widened as well, by the same amount as its spaces. --- src/documents/docs/conformance.md | 16 ++++- src/documents/src/engine/canvas-text.js | 6 +- src/documents/src/engine/dictionary-breaks.js | 2 +- src/documents/src/engine/justification.js | 36 +++++++++-- src/documents/src/engine/layout.js | 4 +- .../tests/browser/cjk-justification.spec.js | 31 ++++++++++ src/documents/tests/cjk-justification.test.js | 59 +++++++++++++++++++ 7 files changed, 143 insertions(+), 11 deletions(-) create mode 100644 src/documents/tests/browser/cjk-justification.spec.js create mode 100644 src/documents/tests/cjk-justification.test.js diff --git a/src/documents/docs/conformance.md b/src/documents/docs/conformance.md index ccbad8ff6..dc9f248d8 100644 --- a/src/documents/docs/conformance.md +++ b/src/documents/docs/conformance.md @@ -7330,9 +7330,19 @@ recorded: line positions follow the browser's fonts, not a reference application's. Remaining gaps: `w:kinsoku w:val="0"`, `w:wordWrap`, `w:overflowPunct` (hanging punctuation), `w:topLinePunct`, custom `noLineBreaksAfter`/`noLineBreaksBefore` lists and `strictFirstAndLastChars` are -not read; justified Chinese and Japanese lines stretch only their spaces, not -the gaps between characters; word movement, word count and spelling still treat -a run of ideographs as one word. +not read; word movement, word count and spelling still treat a run of +ideographs as one word. + +Justified (`w:jc w:val="both"`, §17.3.1.13, §17.18.44) Chinese and Japanese +lines are filled to the margin: besides spaces, each gap where the line could +have broken beside an ideograph or kana is widened by the same amount, as +letter spacing on the character before it. Gaps the East Asian line rules close +(before `。`, after `「`) and the end of the line are not widened; Latin words +keep their letter spacing. Final lines and lines after center, right or decimal +tabs keep their existing behavior. `tests/cjk-justification.test.js` covers the +widths, carets and mixed lines. Remaining gaps: the reference application's +exact distribution (compression of punctuation, `w:jc w:val="distribute"`) is +not reproduced. ## Body editing with retained nonempty headers and footers diff --git a/src/documents/src/engine/canvas-text.js b/src/documents/src/engine/canvas-text.js index 548602c4c..4b924bdd6 100644 --- a/src/documents/src/engine/canvas-text.js +++ b/src/documents/src/engine/canvas-text.js @@ -1,3 +1,4 @@ +import { IDEOGRAPHIC } from './dictionary-breaks.js'; import { prepareLegacyGpos, prefetchLegacyGpos, legacyGposFamily } from './gpos-only-kerning.js'; import {prepareScriptFontSizes,scriptFontSizeReady} from './script-font-size.js'; import { prepareShapeAutofit } from './shape-autofit.js'; @@ -135,7 +136,10 @@ export function validateCanvasTextSupport(pkg) { setCanvasTextStyle(context, {}); const spaced = pkg.paragraphs.some(p => p.markFormat?.characterSpacing || p.runs.some(r => r.characterSpacing)) || listMarkers(pkg.paragraphs, pkg.numbering).some(m => m?.level.markerStyle.characterSpacing); if (spaced) setCanvasTextStyle(context, { characterSpacing: 1 }); - if (pkg.paragraphs.some(p => p.format?.alignment === 'both')) setCanvasTextStyle(context, { justificationSpacing: 1 }); + const justified = pkg.paragraphs.filter(p => p.format?.alignment === 'both'); + if (justified.length) setCanvasTextStyle(context, { justificationSpacing: 1 }); + // Justified Chinese and Japanese widen the gaps between characters with letter spacing (justification.js). + if (justified.some(p => p.runs.some(r => IDEOGRAPHIC.test(r.text)))) setCanvasTextStyle(context, { characterSpacing: 1 }); } function canvasFontRequirements(pkg) { diff --git a/src/documents/src/engine/dictionary-breaks.js b/src/documents/src/engine/dictionary-breaks.js index 2aa54edae..8ccc00fa4 100644 --- a/src/documents/src/engine/dictionary-breaks.js +++ b/src/documents/src/engine/dictionary-breaks.js @@ -15,7 +15,7 @@ const words = typeof Intl?.Segmenter === 'function' ? new Intl.Segmenter(undefin // punctuation and currency signs) for Japanese and Chinese. Nor does a line break before a hyphen, // on either side of a straight quote, or inside a doubled dash. Hangul is left to the space rules: // Korean is wrapped between words. Spaces and inline objects keep their own rules. -const IDEOGRAPHIC = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Bopomofo}\u3001-\u303f\u30fb\u30fc\uff01-\uff65\uffe0-\uffe6]/u; +export const IDEOGRAPHIC = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Bopomofo}\u3001-\u303f\u30fb\u30fc\uff01-\uff65\uffe0-\uffe6]/u; const NO_START = new Set('"\'-‐‑–!%),.:;?]}¢°·’”‰′″℃∶、。〃〆〉》」』】〕〗〙〞〟ぁぃぅぇぉっゃゅょゎゕゖ゛゜ゝゞ゠ァィゥェォッャュョヮヵヶ・ーヽヾㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿ々〻‥…﹚﹜!"%'),.:;?]}~。」、・ァィゥェォャュョッー゙゚¢'); const NO_END = new Set('"\'$([{£¥·‘“〈《「『【〔〖〘〝﹙﹛$([{「£¥'); const OPEN_SPACE = /[\s\ufffc]/u; diff --git a/src/documents/src/engine/justification.js b/src/documents/src/engine/justification.js index 58e9edfae..2bbf8f346 100644 --- a/src/documents/src/engine/justification.js +++ b/src/documents/src/engine/justification.js @@ -1,4 +1,5 @@ import { UnsupportedDocumentError } from './errors.js'; +import { IDEOGRAPHIC } from './dictionary-breaks.js'; // Display spacing only: source text and run properties never acquire this value. // Native Canvas word-spacing expands U+0020 and U+00A0 without breaking shaping. @@ -7,15 +8,42 @@ export function checkJustificationText(text, alignment) { if (alignment === 'both' && /\p{Script=Arabic}/u.test(text)) throw new UnsupportedDocumentError('Arabic kashida justification'); } +/** + * Chinese and Japanese are written without spaces: a justified line of them is filled by widening + * the gaps between characters where the line could have broken (an ideograph or kana on either + * side, dictionary-breaks.js), as well as its spaces. The indices of the characters followed by + * such a gap, within [first, last - 1). + */ +function ideographicGaps(span, first, last) { + const gaps = new Set(); + for (let i = first; i < last - 1; i++) { + const c = span[i], next = span[i + 1]; + if (c.wordBreak && !c.image && !next.image && !next.ignoredCellBreak && (IDEOGRAPHIC.test(c.text) || IDEOGRAPHIC.test(next.text))) gaps.add(i); + } + return gaps; +} + export function justificationRange(span, measured, right, tab) { let first = span.findLastIndex(c => c.text === '\t') + 1, last = span.length; while (first < last && (span[first].ignoredCellBreak || justificationSpace(span[first].text))) first++; while (last > first && (span[last - 1].ignoredCellBreak || justificationSpace(span[last - 1].text))) last--; - const gaps = span.slice(first, last).filter(c => justificationSpace(c.text)).length; + const spaces = span.slice(first, last).filter(c => justificationSpace(c.text)).length; const extra = right - measured.stops.at(-1).x + measured.ignoredTrailingWidth; - if (!gaps || extra <= 1e-6) return null; // Earlier fields stay anchored. Justification can expand the final left tab // field; other tab alignments require a separate compatible algorithm. - if (tab && tab.alignment !== 'left') throw new UnsupportedDocumentError('justification after center, right or decimal tabs'); - return { first, last, gaps, spacing: extra / gaps }; + const anchored = tab && tab.alignment !== 'left'; + const ideographs = anchored ? new Set() : ideographicGaps(span, first, last); + const gaps = spaces + ideographs.size; + if (!gaps || extra <= 1e-6) return null; + if (anchored) throw new UnsupportedDocumentError('justification after center, right or decimal tabs'); + return { first, last, gaps, spacing: extra / gaps, ideographs }; +} + +/** The style of span[index] on a line justified with `spacing` (px) across `range`'s gaps. */ +export function justifiedStyle(style, range, index, spacing) { + // Canvas word spacing widens spaces; an ideograph followed by a gap takes the same width as + // letter spacing, which character spacing expresses in points. + return range.ideographs.has(index) + ? { ...style, justificationSpacing: spacing, characterSpacing: (style.characterSpacing || 0) + spacing * 3 / 4 } + : { ...style, justificationSpacing: spacing }; } diff --git a/src/documents/src/engine/layout.js b/src/documents/src/engine/layout.js index c1303d7f2..c2416f8af 100644 --- a/src/documents/src/engine/layout.js +++ b/src/documents/src/engine/layout.js @@ -17,7 +17,7 @@ import { pageGeometry, readingPageGeometry } from './page-settings.js'; import { layoutParagraphBorders } from './paragraph-border-layout.js'; import { layoutTableBorders, tableRowBorderWidths, tableBorderWidth, cellBorderLayoutWidth, tableHorizontalInsets } from './table-border-layout.js'; import { cellWritingTopBorder, rowTableBorder } from './cell-borders.js'; -import { checkJustificationText, justificationRange } from './justification.js'; +import { checkJustificationText, justificationRange, justifiedStyle } from './justification.js'; import { contractionMetrics, fitContractedWord } from './justification-contraction.js'; import { borderOutset, visibleParagraphBorders } from './paragraph-borders.js'; import { documentBlocks, fixedColumnWidths, roundColumnWidths, autoColumnWidths, rowGrid, spanWidth, headerRowCount, minimumSpanWidth } from './tables.js'; @@ -998,7 +998,7 @@ function measureParagraph(p, documentOffset, measure, page, listMarker, numberin let spacing = range.spacing; for (let attempt = 0; attempt < 4; attempt++) { const expanded = new LineBuilder(left, right, format, page, measure, offset); - span.forEach((c, index) => expanded.add(index >= range.first && index < range.last && !c.image && (!/^\p{White_Space}+$/u.test(c.text) || c.text === ' ' || c.text === '\u00a0') ? { ...c, style: { ...c.style, justificationSpacing: spacing } } : c)); + span.forEach((c, index) => expanded.add(index >= range.first && index < range.last && !c.image && (!/^\p{White_Space}+$/u.test(c.text) || c.text === ' ' || c.text === '\u00a0') ? { ...c, style: justifiedStyle(c.style, range, index, spacing) } : c)); measured = { ...expanded.finish(markStyle, ignoreMark, !!marker), justificationSpacing: spacing }; const overflow = measured.stops.at(-1).x - measured.ignoredTrailingWidth - right; if (overflow <= 1e-6) break; diff --git a/src/documents/tests/browser/cjk-justification.spec.js b/src/documents/tests/browser/cjk-justification.spec.js new file mode 100644 index 000000000..8d47b5281 --- /dev/null +++ b/src/documents/tests/browser/cjk-justification.spec.js @@ -0,0 +1,31 @@ +import { test, expect } from '@playwright/test'; + +// Justified Chinese and Japanese lines reach the right margin with the browser's own glyph advances +// and letter spacing, and the shaped text of each line ends where its carets do. +test('fills justified Chinese and Japanese lines to the margin with native canvas metrics', async ({ page }) => { + await page.goto('/playground.html'); + await expect(page.getByRole('textbox', { name: 'Document text' })).toHaveValue(/A small beginning/); + const result = await page.evaluate(async () => { + const { measureCanvasText } = await import('/src/engine/canvas-text.js'); + const { layoutDocument } = await import('/src/engine/layout.js'); + const { paragraph } = await import('/src/engine/model.js'); + const ctx = document.createElement('canvas').getContext('2d'); + const measure = (t, s) => measureCanvasText(ctx, t, s); + const texts = [ + '这是一段很长的中文句子,用来测试两端对齐是否正确。每一行都应该到达右边距,而不是参差不齐。中文排版中两端对齐是最常见的段落对齐方式。', + '日本語の文章です。「括弧」や、句読点が行頭に来ないことを確認します。ひらがなとカタカナ、そしてチョコレートも。', + '混合 English words 的行也应该对齐,字与字之间的间距会被均匀地拉开,Word 2016 也是如此。', + ]; + const page = { width: 300, height: 4000, margin: 20 }; + const layout = layoutDocument(texts.map(t => paragraph(t, { format: { spaceAfter: 0, alignment: 'both' } })), measure, page); + return layout.lines.filter(l => !l.paragraphEnd).map(line => { + const last = line.fragments.at(-1); + return { edge: line.stops.at(-1).x - (line.ignoredTrailingWidth || 0), lastWidth: last.width, lastMeasured: measure(last.text, last).width }; + }); + }); + expect(result.length).toBeGreaterThan(6); + for (const line of result) { + expect(Math.abs(line.edge - 280)).toBeLessThan(0.5); + expect(line.lastMeasured).toBeCloseTo(line.lastWidth, 3); + } +}); diff --git a/src/documents/tests/cjk-justification.test.js b/src/documents/tests/cjk-justification.test.js new file mode 100644 index 000000000..bffe0846f --- /dev/null +++ b/src/documents/tests/cjk-justification.test.js @@ -0,0 +1,59 @@ +import { it, expect } from 'vitest'; +import { paragraph } from '../src/engine/model.js'; +import { layoutDocument, caretRect, hitTest } from '../src/engine/layout.js'; +import { canvasWordSpacing, canvasLetterSpacing } from '../src/engine/canvas-text.js'; + +// Canvas metrics: letter spacing follows every character, word spacing every space. +const measure = (text, style) => ({ + width: [...text].length * (16 + canvasLetterSpacing(style)) + (text.match(/[  ]/g)?.length || 0) * canvasWordSpacing(style), + ascent: 16, descent: 4, +}); +// A writing width of 200 px: twelve and a half 16 px characters. +const layout = (text, format = {}) => layoutDocument([paragraph(text, { format: { spaceAfter: 0, alignment: 'both', ...format } })], measure, { width: 220, height: 2000, margin: 10 }); +const lineText = (text, line) => text.slice(line.start, line.end); +const edge = line => line.stops.at(-1).x - (line.hangingRight ? 0 : line.ignoredTrailingWidth); + +it('fills justified Chinese and Japanese lines to the margin between characters', () => { + const text = '这是一段很长的中文句子用来测试两端对齐是否正确并且每一行都应该到达右边距。'; + const result = layout(text), lines = result.lines; + expect(lines.length).toBeGreaterThan(2); + // Before, a line without spaces stayed ragged: twelve characters ended 8 px short of the margin. + for (const line of lines.slice(0, -1)) expect(edge(line)).toBeCloseTo(210, 5); + expect(edge(lines.at(-1))).toBeLessThan(210); + // The gaps are even, and carets follow the widened characters. + const first = lines[0], xs = first.stops.map(s => s.x), steps = xs.slice(1).map((x, i) => x - xs[i]); + // The last character keeps its advance: nothing follows it on the line. + for (const step of steps.slice(0, -1)) expect(step).toBeCloseTo(steps[0], 5); + expect(steps[0]).toBeGreaterThan(16); + const caret = caretRect(result, 5); + expect(caret.x).toBeCloseTo(xs[5], 5); + expect(hitTest(result, caret.page, caret.x + 1, caret.y + caret.height / 2)).toBe(5); + const japanese = '日本語の文章です。「括弧」や、句読点が行頭に来ないことを確認します。'; + for (const line of layout(japanese).lines.slice(0, -1)) expect(edge(line), lineText(japanese, line)).toBeCloseTo(210, 5); +}); + +it('widens spaces and the gaps beside ideographs evenly on a mixed line, and leaves Latin words intact', () => { + const text = 'Hi \u8fd9\u662f\u4e00\u6bb5\u5f88\u957f\u7684\u4e2d\u6587\u53e5\u5b50'; + const line = layout(text).lines[0]; + expect(lineText(text, line)).toBe(text.slice(0, 12)); + expect(edge(line)).toBeCloseTo(210, 5); + const x = offset => line.stops.find(s => s.offset === offset).x; + // Letters of a Latin word keep their advance; the space and each ideographic gap grow alike. + expect(x(1) - x(0)).toBeCloseTo(16, 5); + const space = x(3) - x(2) - 16, ideograph = x(5) - x(4) - 16; + expect(space).toBeGreaterThan(0); + expect(ideograph).toBeCloseTo(space, 5); +}); + +it('keeps final lines, left-aligned paragraphs and characters held together by the line rules natural', () => { + const text = '这是一段很长的中文句子用来测试两端对齐'; + expect(layout(text, { alignment: 'left' }).lines.every(l => l.justificationSpacing === undefined)).toBe(true); + const short = layout('中文。'); + expect(short.lines).toHaveLength(1); + expect(short.lines[0].justificationSpacing).toBeUndefined(); + // A full stop stays with the character before it: no gap is widened between them. + const stops = layout('\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u3002\u4e00\u4e8c\u4e09\u56db').lines[0].stops; + const at = offset => stops.find(s => s.offset === offset).x; + expect(at(1) - at(0)).toBeGreaterThan(16); + expect(at(10) - at(9)).toBeCloseTo(16, 5); +}); From 771bd76bdea66e507337bb2a36d54b9dfdc2a277 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:45:06 -0700 Subject: [PATCH 047/186] Documents: Keep text joined by a word joiner or zero-width no-break space on one line U+FEFF matched the white-space test for break opportunities, so a line could end there and split the text it joins. Neither it nor U+2060 now allows a break. --- src/documents/src/engine/dictionary-breaks.js | 4 +++- src/documents/src/engine/intrinsic-widths.js | 2 +- src/documents/tests/dash-line-breaking.test.js | 6 ++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/documents/src/engine/dictionary-breaks.js b/src/documents/src/engine/dictionary-breaks.js index 8ccc00fa4..e219addcf 100644 --- a/src/documents/src/engine/dictionary-breaks.js +++ b/src/documents/src/engine/dictionary-breaks.js @@ -25,10 +25,12 @@ const OPEN_SPACE = /[\s\ufffc]/u; // punctuation that follows it. U+2010 compounds stay together, as the reference controls under // tests/fixtures/hyphen-wrap show. const BREAK_AFTER = /[\u200b\u2013\u2014]/u; +// A word joiner (or a zero-width no-break space) forbids a break on either side (UAX #14 class WJ). +const WORD_JOINER = /[\u2060\ufeff]/u; function dashBreaks(text, breaks) { for (let index = 0; index < text.length - 1; index++) { const c = text[index], next = text[index + 1]; - if (!BREAK_AFTER.test(c) || OPEN_SPACE.test(next) || c !== '\u200b' && (next === c || NO_START.has(next))) continue; + if (!BREAK_AFTER.test(c) || OPEN_SPACE.test(next) || WORD_JOINER.test(next) || c !== '\u200b' && (next === c || NO_START.has(next))) continue; breaks.add(index + 1); } } diff --git a/src/documents/src/engine/intrinsic-widths.js b/src/documents/src/engine/intrinsic-widths.js index 9e2d9c7d6..d7850d898 100644 --- a/src/documents/src/engine/intrinsic-widths.js +++ b/src/documents/src/engine/intrinsic-widths.js @@ -17,7 +17,7 @@ import { dictionaryBreaks } from './dictionary-breaks.js'; // spaces stay within a word; forced grapheme wrapping remains possible. // Floating anchors have zero text width and do not split the surrounding word. // `wordBreak` marks the end of a Thai, Lao, Khmer or Myanmar word, or a break beside an ideograph or kana (dictionary-breaks.js). -export const breakAfter = (text, image, noBreakHyphen = false, wordBreak = false) => wordBreak || text === OBJECT && !isPositionedImage(image) || text === '\u00ad' || text === '-' && !noBreakHyphen || /\s/u.test(text) && !/[\u00a0\u202f\u2007]/u.test(text); +export const breakAfter = (text, image, noBreakHyphen = false, wordBreak = false) => wordBreak || text === OBJECT && !isPositionedImage(image) || text === '\u00ad' || text === '-' && !noBreakHyphen || /\s/u.test(text) && !/[\u00a0\u202f\u2007\ufeff]/u.test(text); const shapeKey = style => `${(style.fontFamily || style.fontAscii || '').toLowerCase()}/${style.capitalization}/${style.bold}/${style.italic}/${style.fontSize}/${style.verticalAlign}/${style.baselineShift}/${style.characterSpacing}/${style.textScale}/${kerningEnabled(style)}`; function withoutTrailingSpaces(chars, page) { if (page.wrapTrailSpaces) return chars; diff --git a/src/documents/tests/dash-line-breaking.test.js b/src/documents/tests/dash-line-breaking.test.js index 65a867c95..339730db8 100644 --- a/src/documents/tests/dash-line-breaking.test.js +++ b/src/documents/tests/dash-line-breaking.test.js @@ -31,3 +31,9 @@ it('sizes AutoFit cells to the widest part between dashes', () => { const metrics = text => cellIntrinsicWidths({ margins: { left: 0, right: 0 }, content: [{ paragraph: paragraph('', { format: { spaceAfter: 0 }, runs: [{ text }] }), index: 0 }] }, measure, page, undefined, []); expect(metrics('long—er—word')).toMatchObject({ min: 40, max: 96 }); }); + +it('never wraps at a zero-width no-break space or beside a word joiner', () => { + // U+FEFF counted as white space, so a line ended there and split the joined text. + expect(lines('aaaa bbbbbbbbbbbb\ufeffcccccccccccc', 25)).toEqual(['aaaa ', 'bbbbbbbbbbbb\ufeffcccccccccccc']); + expect(lines('x aaaa\u2014\u2060bbbb', 8)).toEqual(['x ', 'aaaa\u2014\u2060bb', 'bb']); +}); From 1f878efee0197b68f72da5c3cb5c33490cfd7282 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:49:18 -0700 Subject: [PATCH 048/186] Documents: Zoom with slow trackpad pinches Each Ctrl + wheel event was rounded to a whole percent on its own, so the small steps of a slow pinch (a pixel or two each) never changed the zoom. The unrounded zoom now carries from one event to the next. --- src/documents/src/views/document-views.js | 16 +++++++++---- .../tests/browser/zoom-scroll-anchor.spec.js | 24 +++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/documents/src/views/document-views.js b/src/documents/src/views/document-views.js index f4665d14c..1739c68c9 100644 --- a/src/documents/src/views/document-views.js +++ b/src/documents/src/views/document-views.js @@ -239,15 +239,23 @@ export function createDocumentViews({ editor, pkg, host, surface, root, error, c } /* ---- Zoom: Ctrl/Cmd + wheel and trackpad pinch (which arrives as a Ctrl + wheel) ---- */ + // The zoom the wheel is heading to before rounding to whole percents, while the zoom is still the + // one it last set: a pinch's events are each too small to change the rounded zoom on their own. + let wheelZoom = null; host.addEventListener('wheel', e => { const engine = editor(); if (!engine) return; if (e.ctrlKey || e.metaKey) { if (mode === 'read' || mode === 'outline') return; e.preventDefault(); - const from = engine.zoom, to = Math.max(0.1, Math.min(2, Math.round(from * Math.exp(-e.deltaY * (e.deltaMode ? 0.05 : 0.0025)) * 100) / 100)); - if (to === from) return; - // Keep the point under the pointer where it is. - w.setZoom?.(to, { clientX: e.clientX, clientY: e.clientY }); + const from = wheelZoom?.shown === engine.zoom ? wheelZoom.exact : engine.zoom; + const exact = Math.max(0.1, Math.min(2, from * Math.exp(-e.deltaY * (e.deltaMode ? 0.05 : 0.0025)))); + const to = Math.round(exact * 100) / 100; + // Rounding must not move a fractional zoom (Page width) against the direction of the wheel. + if (to !== engine.zoom && Math.sign(to - engine.zoom) === Math.sign(exact - engine.zoom)) { + // Keep the point under the pointer where it is. + w.setZoom?.(to, { clientX: e.clientX, clientY: e.clientY }); + } + wheelZoom = { exact, shown: engine.zoom }; } else if (sideToSide && Math.abs(e.deltaY) > Math.abs(e.deltaX)) { // Side to side: the wheel turns pages across. e.preventDefault(); host.scrollLeft += e.deltaY; diff --git a/src/documents/tests/browser/zoom-scroll-anchor.spec.js b/src/documents/tests/browser/zoom-scroll-anchor.spec.js index e1c975330..4ade7f0be 100644 --- a/src/documents/tests/browser/zoom-scroll-anchor.spec.js +++ b/src/documents/tests/browser/zoom-scroll-anchor.spec.js @@ -67,3 +67,27 @@ test('zooming with the wheel keeps the point under the pointer', async ({page}) expect(Math.abs(now.x - start.x), `zoom ${now.zoom}`).toBeLessThan(4 / now.zoom); } }); + +// A trackpad pinch arrives as many Ctrl + wheel events of a pixel or two each. Each step is too +// small to change the zoom by a whole percent on its own, but together they zoom as far as one +// large step does, in either direction and at any zoom. +test('a slow pinch zooms as far as one quick wheel step', async ({page}) => { + await page.setViewportSize({width: 700, height: 800}); + await page.goto('/playground.html'); + await page.getByRole('button', {name: 'Across pages'}).click(); + await expect(page.locator('canvas.canvas-page')).toHaveCount(2); + const zoom = () => page.locator('canvas.canvas-page').first().evaluate(c => c.getBoundingClientRect().width / 816); + const box = await page.locator('canvas.canvas-page').first().boundingBox(); + await page.mouse.move(box.x + 100, Math.min(box.y + 200, 700)); + const pinch = (delta, count) => page.evaluate(({delta, count}) => { + const host = document.querySelector('canvas.canvas-page').closest('.canvas-workspace, .canvas-host') || document.querySelector('canvas.canvas-page').parentElement; + for (let i = 0; i < count; i++) host.dispatchEvent(new WheelEvent('wheel', {deltaY: delta, ctrlKey: true, bubbles: true, cancelable: true, clientX: 200, clientY: 300})); + }, {delta, count}); + const start = await zoom(); + // Forty steps of 1.5 px: exp(60 * 0.0025) is about 1.16. + await pinch(-1.5, 40); + await expect.poll(zoom).toBeGreaterThan(start * 1.12); + const zoomed = await zoom(); + await pinch(1.5, 40); + await expect.poll(zoom).toBeLessThan(zoomed / 1.12); +}); From 758ff0c27e7c83e14c92b5df7f2a81f07b0c265b Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:50:40 -0700 Subject: [PATCH 049/186] Documents: Check justified Chinese in fractional-width table cells Covers the inter-character justification with native canvas advances in table cells, where an overflow of a fraction of a pixel would stop editing. --- .../tests/browser/cjk-justification.spec.js | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/documents/tests/browser/cjk-justification.spec.js b/src/documents/tests/browser/cjk-justification.spec.js index 8d47b5281..43250de96 100644 --- a/src/documents/tests/browser/cjk-justification.spec.js +++ b/src/documents/tests/browser/cjk-justification.spec.js @@ -29,3 +29,28 @@ test('fills justified Chinese and Japanese lines to the margin with native canva expect(line.lastMeasured).toBeCloseTo(line.lastWidth, 3); } }); + +test('fits justified Chinese into fractional-width table cells with native canvas metrics', async ({ page }) => { + await page.goto('/playground.html'); + await expect(page.getByRole('textbox', { name: 'Document text' })).toHaveValue(/A small beginning/); + const failures = await page.evaluate(async () => { + const { TextDocument, paragraph } = await import('/src/engine/model.js'), { layoutDocument } = await import('/src/engine/layout.js'), { pageGeometry } = await import('/src/engine/page-settings.js'), { measureCanvasText } = await import('/src/engine/canvas-text.js'); + const ctx = document.createElement('canvas').getContext('2d'), failures = []; + const measure = (t, s) => measureCanvasText(ctx, t, s); + for (const format of [{}, { textScale: 115, characterSpacing: .5 }, { bold: true, fontSize: 13 }]) for (let i = 0; i < 24; i++) { + const doc = new TextDocument([paragraph()], { width: 5000 + i * 17, left: 720, right: 720 }); doc.insertTable(1, 1); + doc.replace('中文排版中两端对齐是最常见的段落对齐方式,字与字之间的间距会被均匀地拉开。'.repeat(2)); doc.select(0, doc.text.length); doc.setRunFormat(format); doc.setParagraphFormat({ alignment: 'both' }); + try { + const result = layoutDocument(doc.paragraphs, measure, pageGeometry(doc.pageSettings), doc.numbering, doc.tables); + const justified = result.lines.filter(l => l.cellId && l.justificationSpacing); + if (!justified.length) failures.push({ i, format, error: 'no justified line' }); + for (const line of justified) { + const box = result.cells.find(c => c.id === line.cellId && c.page === line.page), right = box.x + box.width - 7.2, end = line.stops.at(-1).x - (line.hangingRight ? 0 : line.ignoredTrailingWidth); + if (Math.abs(end - right) > .01) failures.push({ i, format, end, right }); + } + } catch (error) { failures.push({ i, format, error: error.message }); } + } + return failures; + }); + expect(failures).toEqual([]); +}); From 1a9a0926b7bc72b7fc11cccfd09f318738be173f Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:57:26 -0700 Subject: [PATCH 050/186] Documents: Select and place the caret on Hebrew and Arabic typed into ordinary text Text typed or pasted in Hebrew or Arabic carries no right-to-left run property. Such paragraphs skipped the bidirectional layout path: the browser drew the words right to left, but carets, clicks and selections used left-to-right positions, so selecting one Hebrew word highlighted another. Paragraphs with a strong right-to-left letter now go through the same ordering as marked right-to-left runs. --- src/documents/docs/conformance.md | 8 ++++- src/documents/src/engine/layout.js | 13 +++++--- src/documents/tests/shaped-carets.test.js | 7 ++-- .../tests/unmarked-rtl-layout.test.js | 32 +++++++++++++++++++ 4 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 src/documents/tests/unmarked-rtl-layout.test.js diff --git a/src/documents/docs/conformance.md b/src/documents/docs/conformance.md index dc9f248d8..aa512ab10 100644 --- a/src/documents/docs/conformance.md +++ b/src/documents/docs/conformance.md @@ -12277,7 +12277,13 @@ direction override while retaining native Arabic joining. Colors, backgrounds and initial caret/selection geometry follow the relocated fragments; source UTF-16 offsets and graphemes stay unchanged. Supplementary characters use bidi class representatives only in the level-resolution proxy, not in painted or -serialized text. An unmarked paragraph keeps its preceding layout path. +serialized text. An unmarked paragraph keeps its preceding layout path, unless +it contains a strong right-to-left letter (bidi class R or AL, such as Hebrew +or Arabic typed into an ordinary run): such a paragraph is resolved by the same +UAX #9 path with a left-to-right base level, so its carets, hit testing and +selection follow the visual glyph order instead of mirroring it +(`tests/unmarked-rtl-layout.test.js`). No run properties are added, and digits +of those scripts alone keep the left-to-right path. The existing 21 native cases verify word/number order. Eighteen new original controls in tests/fixtures/bidi-layout exercise three full pages, narrow mixed diff --git a/src/documents/src/engine/layout.js b/src/documents/src/engine/layout.js index c2416f8af..9707b4285 100644 --- a/src/documents/src/engine/layout.js +++ b/src/documents/src/engine/layout.js @@ -29,7 +29,7 @@ import { listMarkers } from './numbering.js'; import { markerGeometry } from './list-layout.js'; import { paragraphEdges } from './paragraph-geometry.js'; import { LineBuilder } from './line-builder.js'; -import { bidiCharacters, bidiLineCharacters, bidiRtlSpace } from './bidi-text.js'; +import { bidiCharacters, bidiLineCharacters, bidiRtlSpace, bidiCharacterType } from './bidi-text.js'; import { createParagraphPaginator } from './pagination.js'; import { paragraphText, paragraphFormat, runFormat } from './model.js'; import { revisionView, runRevisionDisplay, revisionViewKey } from './revisions.js'; @@ -56,6 +56,10 @@ import { paragraphFrameGroups, measureParagraphFrame, placeParagraphFrames, Unsu import { autoHyphenationBreak, hyphenCharacter } from './hyphenation-layout.js'; import { hyphenationOf } from './hyphenation-settings.js'; +// Text with a strong right-to-left letter (bidi class R or AL). Digits and marks of these scripts +// are not: they keep the left-to-right path in an ordinary run. +const RTL_SCRIPTS = /[\u0590-\u08ff\ufb1d-\ufdff\ufe70-\ufefc]|[\u{10800}-\u{10fff}\u{1e800}-\u{1efff}]/u; +const strongRtl = text => RTL_SCRIPTS.test(text) && [...text].some(c => RTL_SCRIPTS.test(c) && ['R', 'AL'].includes(bidiCharacterType(c))); export const PAGE = Object.freeze({ width: 816, height: 1056, margin: 96, gap: 24, lineHeight: 26, paragraphGap: 12, fontSize: 16 }); export const MAX_PAGES = 200; export const font = style => `${style.italic ? 'italic' : 'normal'} ${style.bold ? 700 : 400} ${(style.fontSize ?? 12) * 4 / 3}px ${canvasFontFamily(style)}`; @@ -862,9 +866,10 @@ function measureParagraph(p, documentOffset, measure, page, listMarker, numberin const displayText = inlineDisplayText(p.runs[runIndex], s.segment); chars.push({ text: ignoredCellBreak ? '' : displayText, ignoredCellBreak, ...(noBreakHyphen ? { noBreakHyphen: true } : {}), ...(wordBreaks?.has(s.index + s.segment.length) ? { wordBreak: true } : {}), breakClear: p.runs[runIndex]?.breakClear, start: documentOffset + s.index, end: documentOffset + s.index + s.segment.length, style: textFontStyle(displayText, runStyle), image: runHidden ? undefined : p.runs[runIndex]?.image, positionalTab: p.runs[runIndex]?.positionalTab }); } - // Active RTL import/editing remains guarded while interaction is verified. - // Do not alter the existing unmarked-text path during that rollout. - if (chars.some(c=>c.style.rtl)) chars=bidiCharacters(chars); + // Active RTL import/editing remains guarded while interaction is verified. Hebrew or Arabic typed + // into an ordinary run (no w:rtl) is still right-to-left text (UAX #9): it is ordered by the same + // path, so its carets and selection follow the glyphs instead of mirroring them. + if (chars.some(c=>c.style.rtl) || strongRtl(text)) chars=bidiCharacters(chars); // Leading page/column breaks move the first text line without consuming its // first-line indent. Spaces, tabs, manual line breaks and list markers do // consume it. Keep source offsets and the break-only pagination lines intact. diff --git a/src/documents/tests/shaped-carets.test.js b/src/documents/tests/shaped-carets.test.js index 656d9a809..7098ee7c1 100644 --- a/src/documents/tests/shaped-carets.test.js +++ b/src/documents/tests/shaped-carets.test.js @@ -82,9 +82,10 @@ it('keeps a long clipped space tail out of complete-run caret requests',()=>{ it('does not apply an LTR override to unmarked strong RTL text',()=>{ const p=paragraph('');p.runs=[{...RUN_FORMAT,ligatures:'standard',text:'אבג'}]; - let requests=0; - layoutDocument([p],(text,style)=>{if(style.caretOffsets)requests++;return {width:text.length*4,ascent:12,descent:3};}); - expect(requests).toBe(0); + const requests=[]; + layoutDocument([p],(text,style)=>{if(style.caretOffsets)requests.push(style.bidiDirection);return {width:text.length*4,ascent:12,descent:3};}); + // Its carets are shaped right to left, as an RTL fragment, never as left-to-right text. + expect(requests).toEqual(['rtl']); }); it('keeps cached prefix metrics separate by font style and refreshes them for a new layout',()=>{ diff --git a/src/documents/tests/unmarked-rtl-layout.test.js b/src/documents/tests/unmarked-rtl-layout.test.js new file mode 100644 index 000000000..14dc5d03d --- /dev/null +++ b/src/documents/tests/unmarked-rtl-layout.test.js @@ -0,0 +1,32 @@ +import { it, expect } from 'vitest'; +import { paragraph } from '../src/engine/model.js'; +import { layoutDocument, caretRect, hitTest, selectionRects } from '../src/engine/layout.js'; + +const measure = text => ({ width: [...text].length * 10, ascent: 12, descent: 3 }); +const page = { width: 1000, height: 400, margin: 20 }; + +// Hebrew typed into an ordinary run carries no w:rtl. It is still right-to-left text: its words and +// letters run from right to left, and carets and selections must follow them. +it('orders unmarked Hebrew right to left, with carets and selection on its glyphs', () => { + const text = 'abc שלום עולם def'; + const result = layoutDocument([paragraph(text)], measure, page), line = result.lines[0]; + // abc(20-50) space(50-60), then the Hebrew words in visual order: the second word first. + const word = (start, end) => selectionRects(result, start, end)[0]; + const first = word(4, 8), second = word(9, 13); + expect(second.x).toBeLessThan(first.x); + expect(first.x).toBe(110); expect(first.width).toBe(40); + expect(second.x).toBe(60); + // A caret after the first Hebrew letter sits at that letter's left edge, at the right of the word. + expect(caretRect(result, 5).x).toBe(140); + expect(hitTest(result, 0, 141, line.y + line.height / 2)).toBe(5); + // The Latin text keeps its places on both sides. + expect(caretRect(result, 1).x).toBe(30); + expect(caretRect(result, 15).x).toBe(170); + expect(line.fragments.find(f => f.text.includes('ש')).bidiDirection).toBe('rtl'); +}); + +it('leaves paragraphs without right-to-left letters on the left-to-right path', () => { + const line = layoutDocument([paragraph('plain text 123')], measure, page).lines[0]; + expect(line.bidi).toBeUndefined(); + expect(line.fragments.every(f => f.bidiLevel === undefined)).toBe(true); +}); From 79be812b69dee67ad0f8d16c1e35f30ce4b7cf16 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:06:57 -0700 Subject: [PATCH 051/186] Documents: Open and edit tables whose heading rows are too tall to repeat Heading rows that left a page no room for the table body made layout fail: such a document opened read-only with the table flattened into paragraphs, and typing that grew a heading row was undone with an error. The rows are now laid out once, as ordinary rows, without repeating, as Word does. --- src/documents/docs/conformance.md | 4 ++-- src/documents/src/engine/layout.js | 6 ++---- src/documents/src/engine/table-headers.js | 10 +++++++--- src/documents/tests/canvas-input.test.js | 10 +++++++--- src/documents/tests/canvas-table-headers.test.js | 13 ++++++++++--- 5 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/documents/docs/conformance.md b/src/documents/docs/conformance.md index aa512ab10..ca0771b88 100644 --- a/src/documents/docs/conformance.md +++ b/src/documents/docs/conformance.md @@ -2934,10 +2934,10 @@ see the updated subset and limits above. | Feature | Specification basis | Supported subset and evidence | | --- | --- | --- | -| Header declarations | ECMA-376 Part 1 §17.4.49 `tblHeader`, §17.17.4 common boolean properties | Leading, contiguous marked rows repeat at the top of each later page occupied by the table. Empty/true flags enable repetition; explicit false disables it. Flags after the first unmarked row are ignored for layout and preserved in the package. A table consisting entirely of header rows renders once if it fits a page. | +| Header declarations | ECMA-376 Part 1 §17.4.49 `tblHeader`, §17.17.4 common boolean properties | Leading, contiguous marked rows repeat at the top of each later page occupied by the table. Empty/true flags enable repetition; explicit false disables it. Flags after the first unmarked row are ignored for layout and preserved in the package. A table consisting entirely of header rows renders once; one taller than a page flows across pages as ordinary rows. | | Inheritance and serialization | §17.7.6.11 style `trPr`, §17.4.49 | Unconditional table-style row properties resolve through the based-on chain before direct row declarations. Direct false overrides inherited true. Unchanged declarations and style parts retain their source bytes; edited flags write explicit true/false without expanding repeated display copies into DOCX rows. | | Pagination | §17.4.49; §17.4.6 `cantSplit`, §17.4.80 `trHeight` | The header is measured once and reserves space on each occupied table page. Body cells keep their independent text flow, row keeps, minimum heights, margins and vertical merges. A first body row moved by its keep carries the first header with it. Fixed and AutoFit tables use the same repeated-header placement. Header-only tables remain one placement unit. | -| Merged headers | §17.4.17 `gridSpan`, §17.4.84 `vMerge` | Horizontal and vertical merges wholly inside the header are supported. Authoring a header region extends it to include whole vertical merges. Imports or subsequent merges crossing the header/body boundary reject before save attachment, or roll back the edit. The complete header must fit a page and leave room for body text lines. These restrictions are application limits. | +| Merged headers | §17.4.17 `gridSpan`, §17.4.84 `vMerge` | Horizontal and vertical merges wholly inside the header are supported. Authoring a header region extends it to include whole vertical merges. Imports or subsequent merges crossing the header/body boundary reject before save attachment, or roll back the edit (an application limit). A header too tall to leave a page room for the body, or for one of its text lines, is not repeated: its rows are laid out once as ordinary rows, as the reference application does, rather than refusing the table or the edit that grew it. | | Editing and navigation | Existing source-offset, history and geometry contracts | Repeat rows through selection sets a leading header region; remove repeated headers writes false where needed. Row insertion inside the region extends it; insertion at its boundary creates a body row, including under an inherited header style. Display copies share source positions. Clicking, typing and arrow movement retain the clicked page as a view preference; copy/paste, undo and exported text contain the header once. Selection highlights all visible copies. | Reviewed against official [ECMA-376 Part 1, fifth edition](https://ecma-international.org/publications-and-standards/standards/ecma-376/), diff --git a/src/documents/src/engine/layout.js b/src/documents/src/engine/layout.js index 9707b4285..12be5d9ff 100644 --- a/src/documents/src/engine/layout.js +++ b/src/documents/src/engine/layout.js @@ -551,10 +551,8 @@ function layoutPass(paragraphs, measureBase, page, numbering, tables, pageStorie // tblHeader is retained in nested tables, but those rows do not repeat. // A containing top-level header still repeats its complete nested content. const count = table.parentCellId ? 0 : headerRowCount(table); - if (count) { - const tableHeader = prepareTableHeader(rows.map(row => row.tableRow), count, page, paginateParagraphs); - return [{ tableHeader, height: tableHeader.height + tableHeader.minimum, format: { keepLines: true } }]; - } + const tableHeader = count ? prepareTableHeader(rows.map(row => row.tableRow), count, page, paginateParagraphs) : null; + if (tableHeader) return [{ tableHeader, height: tableHeader.height + tableHeader.minimum, format: { keepLines: true } }]; return mergedRowGroups(rows); }; const measuredPage = page; diff --git a/src/documents/src/engine/table-headers.js b/src/documents/src/engine/table-headers.js index f7949b5c7..3dc46ca18 100644 --- a/src/documents/src/engine/table-headers.js +++ b/src/documents/src/engine/table-headers.js @@ -4,19 +4,23 @@ import { paginateTableRows } from './table-pagination.js'; import { shiftCellClip } from './cell-clipping.js'; import { pageBounds, offsetPage } from './page-bounds.js'; -/** Measure the one source header. Repeated copies never enter document content. */ +/** + * Measure the one source header. Repeated copies never enter document content. Null when the header + * cannot repeat: rows too tall to leave a page room for the table body (or for one of its lines) are + * laid out once, as ordinary rows, as Word does, rather than refusing the table. + */ export function prepareTableHeader(rows, count, page, paginateText) { if (rows.slice(0, count).some((row, i) => row.cells.some(cell => i + cell.rowSpan > count))) throw new Error('A vertical merge cannot cross the repeated-header boundary. Include all of its rows in the header or split the merged cell.'); const headerRows = rows.slice(0, count).map(row => ({ ...row, cells: row.cells.map(cell => ({ ...cell, page: { ...cell.page, height: 1e9, top: cell.margins.top / 15, bottom: cell.margins.bottom / 15 } })) })); const header = paginateTableRows(headerRows, { ...page, height: 1e9, top: 0, bottom: 0 }, 1, 0, paginateText); const body = rows.slice(count), height = header.endY, capacity = page.height - page.top - page.bottom; - if (height > capacity + 1e-6 || body.length && height >= capacity - 1e-6) throw new Error('Repeated header rows must fit on one page and leave room for the table body. Shorten the header or repeat fewer rows.'); + if (height > capacity + 1e-6 || body.length && height >= capacity - 1e-6) return null; for (const row of body) for (const cell of row.cells) { if (cell.page.clipOverflow) continue; const omitHeader = (page.compatibilityMode ?? 12) >= 15 && (row.fixedHeight ?? row.minimumHeight ?? row.minHeight / 15) > capacity - height + 1e-6; const available = capacity - (omitHeader ? 0 : height); - for (const block of paragraphLayoutBlocks(cell.blocks)) if (block.lines.some(line => line.height + paragraphBorderInsets(block) + (cell.margins.top + cell.margins.bottom) / 15 > available + 1e-6)) throw new Error('The table page leaves too little space for a body text line. Reduce the header height or the body font size.'); + for (const block of paragraphLayoutBlocks(cell.blocks)) if (block.lines.some(line => line.height + paragraphBorderInsets(block) + (cell.margins.top + cell.margins.bottom) / 15 > available + 1e-6)) return null; } const naturalMinimum = Math.max(0, ...(body[0]?.cells.map(c => blockMinimumHeight(c.blocks[0]) + (c.margins.top + c.margins.bottom) / 15) || [])); const minimum = body[0]?.fixedHeight == null ? naturalMinimum : Math.min(body[0].fixedHeight, naturalMinimum, capacity - height); diff --git a/src/documents/tests/canvas-input.test.js b/src/documents/tests/canvas-input.test.js index 621c1f5d6..197e1fc2c 100644 --- a/src/documents/tests/canvas-input.test.js +++ b/src/documents/tests/canvas-input.test.js @@ -268,13 +268,17 @@ it('edits a repeated header on the clicked page and keeps its caret there throug editor.redo(); expect(editor.model.text.match(/XTitle/g)).toHaveLength(1); expect(caretRect(editor.layout, editor.model.selection.focus, 'forward', editor.caretPage).page).toBe(copy.page); }); -it('rolls back header/body crossing merges and headers that leave no usable page space', () => { +it('rolls back header/body crossing merges and keeps typing that makes a header too tall to repeat', () => { editor.setPageSettings({ height: 4320, top: 360, bottom: 360 }); editor.model.select(0); editor.insertTable(2, 2); editor.setTableHeaderRows(1); const before = editor.model.snapshot(), history = [...editor.session.history]; changed.mockClear(); errors.mockClear(); expect(editor.execute(() => editor.model.mergeCellBelow())).toBe(false); expect(errors.mock.calls.at(-1)[0].message).toMatch(/header boundary/); expect(editor.model.snapshot()).toEqual(before); - expect(editor.execute(() => editor.model.replace('Tall header '.repeat(100)))).toBe(false); - expect(errors.mock.calls.at(-1)[0].message).toMatch(/header rows must fit/); expect(editor.model.snapshot()).toEqual(before); expect(editor.session.history).toEqual(history); expect(changed).not.toHaveBeenCalled(); + expect(editor.model.snapshot()).toEqual(before); expect(editor.session.history).toEqual(history); + // A header too tall to leave room for the body is laid out once, without repeating, as in Word. + errors.mockClear(); + expect(editor.execute(() => editor.model.replace('Tall header '.repeat(100)))).toBe(true); + expect(errors).not.toHaveBeenCalled(); expect(editor.layout.repeatedLines).toEqual([]); + expect(editor.model.text).toContain('Tall header Tall header'); }); it('selects a painted inline picture, preserves it during adjacent native composition and copies its description as plain text', () => { diff --git a/src/documents/tests/canvas-table-headers.test.js b/src/documents/tests/canvas-table-headers.test.js index 1cfaacbac..818efc9b0 100644 --- a/src/documents/tests/canvas-table-headers.test.js +++ b/src/documents/tests/canvas-table-headers.test.js @@ -117,11 +117,18 @@ it('places a header-only table once with its preceding kept heading and retains const out = model(await DocxPackage.open(await save(pkg, doc))); expect(out.text).toBe(doc.text + '\n'); expect(headerRowCount(out.tables[0])).toBe(2); }); -it('ignores noncontiguous header flags and rejects crossing merges, oversized headers and insufficient body space', async () => { +it('ignores noncontiguous header flags, rejects crossing merges and lays out headers that cannot repeat once', async () => { const pkg = await DocxPackage.open(source), doc = model(pkg), table = doc.tables[0]; table.rows[0].header = false; const plain = layout(doc); expect(headerRowCount(table)).toBe(0); expect(plain.repeatedLines.some(l => l.cellId === table.rows[1].cells[1].id)).toBe(false); table.rows[0].header = true; table.rows[1].header = false; expect(() => layout(doc)).toThrow(/cross.*header boundary/); - table.rows[1].header = true; doc.selectCell(table.rows[0].cells[0].id); doc.replace('Tall header '.repeat(100)); expect(() => layout(doc)).toThrow(/header rows must fit/); doc.undo(); - doc.selectCell(doc.tables[0].rows[2].cells[0].id); doc.setRunFormat({ fontSize: 160 }); doc.replace('W'); expect(() => layout(doc)).toThrow(/too little space|too wide/); + // A header too tall to leave its page room for the body is not repeated, as in Word: the rows are + // laid out once, as ordinary rows, instead of refusing the table (and the typing that grew it). + table.rows[1].header = true; doc.selectCell(table.rows[0].cells[0].id); doc.replace('Tall header '.repeat(100)); + const tall = layout(doc), headerCell = doc.tables[0].rows[0].cells[0].id, first = new Set(doc.tables[0].rows.flatMap(r => r.cells.map(c => c.id))); + expect(tall.repeatedLines.filter(l => first.has(l.cellId))).toEqual([]); + expect(new Set(tall.lines.filter(l => l.cellId === headerCell).map(l => l.page)).size).toBeGreaterThan(1); + expect(tall.lines.some(l => l.cellId === doc.tables[0].rows.at(-1).cells[0].id)).toBe(true); + doc.undo(); + doc.selectCell(doc.tables[0].rows[2].cells[0].id); doc.setRunFormat({ fontSize: 160 }); doc.replace('W'); expect(() => layout(doc)).not.toThrow(/too little space/); await expect(DocxPackage.open(changed(xml => xml.getElementsByTagNameNS(W, 'tblHeader')[0].setAttributeNS(W, 'w:val', 'unknown')))).rejects.toMatchObject({ name: 'UnsupportedDocumentError' }); }); From 10e1a378602c565ede7ba493de652ed3088baef8 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:21:11 -0700 Subject: [PATCH 052/186] Documents: Split a table with Ctrl+Shift+Enter instead of adding an invisible break to the cell In a table cell the keys inserted a column break, which cells do not show, so nothing seemed to happen. They now split the table above the caret's row, or from the first row open a paragraph before the table, as Split Table does. --- src/documents/docs/conformance.md | 2 + src/documents/src/engine/canvas-editor.js | 7 ++- .../browser/table-split-shortcut.spec.js | 44 +++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 src/documents/tests/browser/table-split-shortcut.spec.js diff --git a/src/documents/docs/conformance.md b/src/documents/docs/conformance.md index ca0771b88..ff044577e 100644 --- a/src/documents/docs/conformance.md +++ b/src/documents/docs/conformance.md @@ -8861,6 +8861,8 @@ as advancing to the next available column, or to the next page in the last column or a single-column section. The body text importer, paginator, editor, and exporter now retain this distinction from authored page and line breaks. Ctrl+Shift+Enter inserts a column break; plain-text copy substitutes a newline. +In a table cell, where breaks do not advance, the keys split the table above the +caret's row instead (from the first row: a paragraph before the table). The internal marker is serialized as a break element, never an XML control character. Untouched package parts remain unchanged. diff --git a/src/documents/src/engine/canvas-editor.js b/src/documents/src/engine/canvas-editor.js index 65baa9aab..407ebb0bd 100644 --- a/src/documents/src/engine/canvas-editor.js +++ b/src/documents/src/engine/canvas-editor.js @@ -44,7 +44,7 @@ import { paintColor } from './colors.js'; import { TextDocument, boundaries, locate, paragraphFormat, paragraphText } from './model.js'; import { cellContext } from './tables.js'; import { cellBlockRects, cellBlockPlacements, wholeRows } from './table-selection.js'; -import { clearCells } from './table-editing.js'; +import { clearCells, splitTable } from './table-editing.js'; import { paragraphEdges } from './paragraph-geometry.js'; import { pageGeometry } from './page-settings.js'; import { pageBorderStrokes } from './page-borders.js'; @@ -1699,7 +1699,10 @@ export class CanvasEditor { if (e.key === 'Enter' && !e.altKey) { e.preventDefault(); if (!this.readOnly && !this.viewOnly) this.narrowCellBlock(); - const done = this.execute(() => mod ? (e.shiftKey ? this.model.insertColumnBreak() : this.model.insertPageBreak()) : e.shiftKey ? this.model.replace('\v') : this.model.insertParagraph()); + // In a table, Ctrl+Shift+Enter splits it above the caret's row (from the first row: a paragraph + // before it), as Word does; a column break in a cell would not show. + const columnBreak = () => this.model.tableContext ? splitTable(this.model) : this.model.insertColumnBreak(); + const done = this.execute(() => mod ? (e.shiftKey ? columnBreak() : this.model.insertPageBreak()) : e.shiftKey ? this.model.replace('\v') : this.model.insertParagraph()); // A new paragraph ends the word before it, which may be an address to link. if (done && !mod && !e.shiftKey) { const caret = this.model.selection.focus, place = locate(this.model.paragraphs, caret); diff --git a/src/documents/tests/browser/table-split-shortcut.spec.js b/src/documents/tests/browser/table-split-shortcut.spec.js new file mode 100644 index 000000000..e5ff720ef --- /dev/null +++ b/src/documents/tests/browser/table-split-shortcut.spec.js @@ -0,0 +1,44 @@ +import { test, expect } from '@playwright/test'; +import { onTab } from './helpers/editor-tools.js'; + +// Ctrl+Shift+Enter (Cmd+Shift+Return on a Mac) in a table splits it above the caret's row, as Split +// Table does; in the first row it opens a paragraph before the table. A column break in a cell would +// show nothing (breaks in cells do not advance), so the keys would seem to do nothing. +const input = page => page.getByRole('textbox', { name: 'Document text' }); +const shape = page => page.evaluate(() => { + const m = window.__docs.current.view.engine.model; + return { tables: m.tables.map(t => t.rows.map(r => r.cells.map(c => m.paragraphs.filter(p => p.cellId === c.id).map(p => p.runs.map(x => x.text).join('')).join('/')))), + body: m.paragraphs.filter(p => !p.cellId).map(p => p.runs.map(x => x.text).join('')), inCell: !!m.tableContext }; +}); +async function table(page) { + await page.goto('/mock.html#/new'); + await expect((await onTab(page, 'home')).getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + await input(page).focus(); + await (await onTab(page, 'insert')).getByRole('button', { name: 'Insert table', exact: true }).click(); + await page.getByRole('menuitem', { name: '2 columns, 3 rows' }).click(); + for (const [i, value] of ['a0', 'a1', 'b0', 'b1', 'c0', 'c1'].entries()) { if (i) await input(page).press('Tab'); await page.keyboard.type(value); } +} +const goToCell = (page, r, c) => page.evaluate(([r, c]) => { + const e = window.__docs.current.view.engine; + e.model.selectCell(e.model.tables[0].rows[r].cells[c].id); e.syncInput(); e.paint(); e.onSelection(); e.focus(); +}, [r, c]); + +test('Ctrl+Shift+Enter splits a table, or opens a paragraph above it from the first row', async ({ page }) => { + await table(page); + await goToCell(page, 1, 1); + await input(page).press('ControlOrMeta+Shift+Enter'); + expect(await shape(page)).toEqual({ tables: [[['A0', 'A1']], [['B0', 'B1'], ['C0', 'C1']]], body: ['', ''], inCell: false }); + // The caret waits in the paragraph between the two tables. + await page.keyboard.type('Between'); + expect((await shape(page)).body).toEqual(['Between', '']); + await page.keyboard.press('ControlOrMeta+z'); + await page.keyboard.press('ControlOrMeta+z'); + expect((await shape(page)).tables).toEqual([[['A0', 'A1'], ['B0', 'B1'], ['C0', 'C1']]]); + + // In the first row of a table at the top of the document: a paragraph opens before the table. + await goToCell(page, 0, 0); + await input(page).press('ControlOrMeta+Shift+Enter'); + await page.keyboard.type('Title'); + expect(await shape(page)).toEqual({ tables: [[['A0', 'A1'], ['B0', 'B1'], ['C0', 'C1']]], body: ['Title', ''], inCell: false }); + expect(await page.evaluate(() => window.__docs.current.view.engine.model.paragraphs[0].cellId || null)).toBe(null); +}); From c8e1531820b37ac334cbe42d7bc729e257751bcb Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:24:25 -0700 Subject: [PATCH 053/186] Documents: Keep a row's exact height exact when dragging its border or typing its height Dragging a row border and the Height box on Table Layout always set the row to 'At least', so a row set to 'Exactly' grew with its text after a resize. Rows of exact height now keep their rule, as resizing the whole table already did. --- src/documents/src/engine/table-editing.js | 15 ++++++++---- src/documents/src/views/table-pointer.js | 4 ++-- src/documents/src/views/table-tools.js | 2 +- .../tests/browser/table-pointer.spec.js | 23 +++++++++++++++++++ 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/documents/src/engine/table-editing.js b/src/documents/src/engine/table-editing.js index 031b56cac..9d8c67cb4 100644 --- a/src/documents/src/engine/table-editing.js +++ b/src/documents/src/engine/table-editing.js @@ -590,15 +590,20 @@ export function setSelectedColumnWidth(model, twips, { widths: measured } = {}) return setColumnWidths(model, table, integerWidths(next, Math.round(sum(next))), { layout: 'fixed' }); } -/** Height spinner and row-border dragging: the rows take this minimum height (twips). */ +/** + * Height spinner and row-border dragging: the rows take this height (twips). A `null` rule keeps a row + * of exact height exact, as dragging its border or typing its height does; other rows become at least + * that tall. + */ export function setRowsHeight(model, rows, twips, rule = 'atLeast') { if (!Number.isFinite(twips) || twips < 0 || twips > 31680) throw new Error('Enter a row height up to 22 inches.'); - if (!['atLeast', 'exact', 'auto'].includes(rule)) throw new Error('Choose at least, exactly or automatic.'); - const value = rule === 'auto' ? 0 : Math.round(twips); - const changed = rows.filter(row => !row.heightExplicit || row.heightRule !== rule || row.minHeight !== value); + if (rule !== null && !['atLeast', 'exact', 'auto'].includes(rule)) throw new Error('Choose at least, exactly or automatic.'); + const ruleOf = row => rule ?? (row.heightExplicit && row.heightRule === 'exact' ? 'exact' : 'atLeast'); + const valueOf = row => ruleOf(row) === 'auto' ? 0 : Math.round(twips); + const changed = rows.filter(row => !row.heightExplicit || row.heightRule !== ruleOf(row) || row.minHeight !== valueOf(row)); if (!changed.length) return false; model.checkpoint(); - for (const row of changed) Object.assign(row, { heightRule: rule, minHeight: value, heightExplicit: true, heightChanged: true }); + for (const row of changed) Object.assign(row, { heightRule: ruleOf(row), minHeight: valueOf(row), heightExplicit: true, heightChanged: true }); model.didChange(); return true; } export function setSelectedRowHeight(model, twips, rule = 'atLeast') { diff --git a/src/documents/src/views/table-pointer.js b/src/documents/src/views/table-pointer.js index bcb1bd988..49793d652 100644 --- a/src/documents/src/views/table-pointer.js +++ b/src/documents/src/views/table-pointer.js @@ -193,10 +193,10 @@ export function attachTablePointer(engine, { readOnly, widths, heights, error = const at = point(e) || d.last; const table = d.frame?.table; if (d.kind === 'column' && d.moved) { commit(m => T.setColumnWidths(m, table, d.widths, { layout: 'fixed' })); refreshRuler(); } - else if (d.kind === 'row' && d.moved) commit(m => T.setRowsHeight(m, [table.rows[d.row]], d.height, 'atLeast')); + else if (d.kind === 'row' && d.moved) commit(m => T.setRowsHeight(m, [table.rows[d.row]], d.height, null)); else if (d.kind === 'resize' && d.moved) commit(m => { T.setColumnWidths(m, table, d.widths, { layout: 'fixed' }); - table.rows.forEach((row, i) => { if (d.heights[i]) T.setRowsHeight(m, [row], d.heights[i], row.heightRule === 'exact' ? 'exact' : 'atLeast'); }); + table.rows.forEach((row, i) => { if (d.heights[i]) T.setRowsHeight(m, [row], d.heights[i], null); }); return true; }); else if (d.kind === 'move') { diff --git a/src/documents/src/views/table-tools.js b/src/documents/src/views/table-tools.js index 6a3e640df..b025ca1f9 100644 --- a/src/documents/src/views/table-tools.js +++ b/src/documents/src/views/table-tools.js @@ -365,7 +365,7 @@ export function createTableTools({ editor, pkg, error = () => {}, onChange = () onchange: () => { if (!input.checkValidity() || input.value === '') { input.reportValidity(); return; } const twips = Math.round(Number(input.value) * 1440); - withTable((m, b, engine) => key === 'height' ? T.setSelectedRowHeight(m, twips, 'atLeast') : T.setSelectedColumnWidth(m, twips, { widths: widthsOf(engine, b.table) })); + withTable((m, b, engine) => key === 'height' ? T.setSelectedRowHeight(m, twips, null) : T.setSelectedColumnWidth(m, twips, { widths: widthsOf(engine, b.table) })); }, onkeydown: e => { if (e.key === 'Enter' && !isComposing(e)) { e.preventDefault(); input.dispatchEvent(new Event('change')); } } }); when(input, inTable); diff --git a/src/documents/tests/browser/table-pointer.spec.js b/src/documents/tests/browser/table-pointer.spec.js index d7c2cdbc5..5e2f29c91 100644 --- a/src/documents/tests/browser/table-pointer.spec.js +++ b/src/documents/tests/browser/table-pointer.spec.js @@ -59,6 +59,29 @@ test('dragging column and row borders resizes them, with one undo each', async ( expect(rows[0].height).toBeGreaterThan(Math.round((box[1].h + 20) * 15)); }); +test('a row of exact height stays exact when its border is dragged or its height typed', async ({ page }) => { + await newTable(page, 2, 2); + // Row 1 at exactly 0.5 in (Table Properties > Row > Exactly). + await page.evaluate(() => { + const e = window.__docs.current.view.engine, row = e.model.tables[0].rows[0]; + e.execute(() => { Object.assign(row, { heightRule: 'exact', minHeight: 720, heightExplicit: true, heightChanged: true }); e.model.didChange(); return true; }); + }); + const box = (await cells(page))[0]; + const rowBottom = { x: box[0].x + box[0].w / 2, y: box[0].y + box[0].h }; + await drag(page, rowBottom, { x: rowBottom.x, y: rowBottom.y + 30 }); + let row = (await tables(page))[0].rows[0]; + expect(row).toMatchObject({ rule: 'exact', explicit: true }); + expect(row.height).toBeGreaterThan(720 + 20 * 15); + // The Height box on Table Layout keeps the rule too; a row without one becomes at least that tall. + await page.evaluate(() => { const e = window.__docs.current.view.engine; e.model.setCellBlock({ tableId: e.model.tables[0].id, top: 0, bottom: 1, left: 0, right: 2 }); e.syncInput(); e.paint(); e.onSelection(); e.focus(); }); + await onTab(page, 'tableLayout'); + const height = page.locator('#ribbon-panel-tableLayout').getByRole('spinbutton', { name: 'Height (in)', exact: true }); + await height.fill('0.8'); await height.press('Enter'); + const rows = (await tables(page))[0].rows; + expect(rows[0]).toMatchObject({ rule: 'exact', height: 1152 }); + expect(rows[1]).toMatchObject({ rule: 'atLeast', height: 1152 }); +}); + test('the + marks insert rows and columns; the move handle selects the table; keys follow desktop conventions', async ({ page }) => { await newTable(page, 2, 2); let box = await cells(page); From 220800db33b67cd0b9eccd62a7d9d821e3b68693 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:27:55 -0700 Subject: [PATCH 054/186] Documents: Keep every chart series when a pie or doughnut type is chosen on the way to another Insert Chart deleted all but the first series as soon as Pie or Doughnut was chosen, and the arrow keys choose a type as they move, so arrowing from Column to Scatter lost the data. Those types now hide the other series instead. --- src/documents/src/views/insert-chart.js | 16 ++++--- .../tests/browser/chart-type-switch.spec.js | 45 +++++++++++++++++++ 2 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 src/documents/tests/browser/chart-type-switch.spec.js diff --git a/src/documents/src/views/insert-chart.js b/src/documents/src/views/insert-chart.js index 502d3f066..30c1efa7e 100644 --- a/src/documents/src/views/insert-chart.js +++ b/src/documents/src/views/insert-chart.js @@ -54,10 +54,12 @@ export function openChartDialog({ initial = null, onInsert, onClose }) { const preview = el('canvas.insert-chart-preview', { width: 440, height: 260, 'aria-hidden': 'true' }); const status = el('p.field-hint', { 'aria-live': 'polite' }); const single = () => ['pie', 'doughnut'].includes(type); + // A pie or doughnut shows the first series only. The others stay in the data, hidden, so passing over + // those types (the arrow keys choose as they go) does not delete them. + const shown = () => single() ? data.series.slice(0, 1) : data.series; function setType(next) { type = next; for (const o of options) { o.setAttribute('aria-selected', String(o.dataset.type === type)); o.tabIndex = o.dataset.type === type ? 0 : -1; } - if (single() && data.series.length > 1) data.series = data.series.slice(0, 1); drawGrid(); render(); } // A value cell holds what was typed; one that is not a number ("1,200", "12%", "$5") is marked, and Insert @@ -66,17 +68,17 @@ export function openChartDialog({ initial = null, onInsert, onClose }) { const cellInput = (value, label, onInput, numeric = false) => el('input.input', { type: 'text', inputMode: numeric ? 'decimal' : 'text', value: String(value ?? ''), 'aria-label': label, oninput: e => { onInput(e.target.value); if (numeric) { if (Number.isFinite(numberOf(e.target.value))) e.target.removeAttribute('aria-invalid'); else e.target.setAttribute('aria-invalid', 'true'); } render(); } }); function drawGrid() { - const head = el('tr', {}, el('th', {}, ''), ...data.series.map((s, j) => el('th', {}, cellInput(s.name, `Series ${j + 1} name`, v => { s.name = v; }))), + const head = el('tr', {}, el('th', {}, ''), ...shown().map((s, j) => el('th', {}, cellInput(s.name, `Series ${j + 1} name`, v => { s.name = v; }))), el('th', {}, el('button.btn.btn-ghost.btn-sm', { type: 'button', disabled: single() || data.series.length >= 20, title: 'Add a series', onclick: () => { data.series.push({ name: `Series ${data.series.length + 1}`, values: data.categories.map(() => 0), color: CHART_PALETTE[data.series.length % CHART_PALETTE.length] }); drawGrid(); render(); } }, '+ Series'))); const body = data.categories.map((category, i) => el('tr', {}, el('th', {}, cellInput(category, `Category ${i + 1}`, v => { data.categories[i] = v; })), - ...data.series.map((s, j) => el('td', {}, cellInput(s.values[i], `${s.name || `Series ${j + 1}`}, ${category}`, v => { s.values[i] = v; }, true))), + ...shown().map((s, j) => el('td', {}, cellInput(s.values[i], `${s.name || `Series ${j + 1}`}, ${category}`, v => { s.values[i] = v; }, true))), el('td', {}, el('button.icon-btn.insert-chart-remove', { type: 'button', 'aria-label': `Remove ${category || `row ${i + 1}`}`, title: 'Remove row', disabled: data.categories.length <= 1, onclick: () => { data.categories.splice(i, 1); for (const s of data.series) s.values.splice(i, 1); drawGrid(); render(); } }, '×')))); - const foot = el('tr', {}, el('td', { colSpan: data.series.length + 2 }, + const foot = el('tr', {}, el('td', { colSpan: shown().length + 2 }, el('button.btn.btn-ghost.btn-sm', { type: 'button', disabled: data.categories.length >= 100, onclick: () => { data.categories.push(type.startsWith('scatter') ? String(data.categories.length + 1) : `Category ${data.categories.length + 1}`); for (const s of data.series) s.values.push(0); drawGrid(); render(); } }, '+ Row'), - data.series.length > 1 ? el('button.btn.btn-ghost.btn-sm', { type: 'button', onclick: () => { data.series.pop(); drawGrid(); render(); } }, '− Series') : null)); + shown().length > 1 ? el('button.btn.btn-ghost.btn-sm', { type: 'button', onclick: () => { data.series.pop(); drawGrid(); render(); } }, '− Series') : null)); grid.replaceChildren(el('thead', {}, head), el('tbody', {}, ...body, foot)); } - const current = () => ({ type, title: title.value.trim(), legend: legend.value || null, categories: data.categories.map(c => String(c)), series: data.series.map((s, j) => ({ name: s.name || `Series ${j + 1}`, values: s.values.map(v => Number.isFinite(numberOf(v)) ? numberOf(v) : 0), color: s.color || CHART_PALETTE[j % CHART_PALETTE.length], ...(s.colors ? { colors: s.colors } : {}) })) }); + const current = () => ({ type, title: title.value.trim(), legend: legend.value || null, categories: data.categories.map(c => String(c)), series: shown().map((s, j) => ({ name: s.name || `Series ${j + 1}`, values: s.values.map(v => Number.isFinite(numberOf(v)) ? numberOf(v) : 0), color: s.color || CHART_PALETTE[j % CHART_PALETTE.length], ...(s.colors ? { colors: s.colors } : {}) })) }); let pending = 0; async function render() { const ticket = ++pending; @@ -96,7 +98,7 @@ export function openChartDialog({ initial = null, onInsert, onClose }) { el('div.insert-chart-layout', {}, typeList, el('div.insert-chart-editor', {}, el('div.insert-chart-meta', {}, el('label.field', {}, el('span.field-label', {}, 'Title'), title), el('label.field', {}, el('span.field-label', {}, 'Legend'), legend)), preview, status, el('div.insert-chart-data', {}, grid)))], onSubmit: () => { - for (const [j, s] of data.series.entries()) for (const [i, v] of s.values.entries()) { + for (const [j, s] of shown().entries()) for (const [i, v] of s.values.entries()) { if (!Number.isFinite(numberOf(v))) throw new Error(`“${String(v).trim()}” (${s.name || `Series ${j + 1}`}, ${data.categories[i]}) is not a number. Enter numbers such as 1200 or 1.5, without separators, signs or units.`); } return onInsert(current()); diff --git a/src/documents/tests/browser/chart-type-switch.spec.js b/src/documents/tests/browser/chart-type-switch.spec.js new file mode 100644 index 000000000..98ca23974 --- /dev/null +++ b/src/documents/tests/browser/chart-type-switch.spec.js @@ -0,0 +1,45 @@ +import { test, expect } from '@playwright/test'; +import { onTab } from './helpers/editor-tools.js'; + +// A pie or doughnut chart shows one series. Passing over those types in Insert Chart (clicking them, or +// arrowing through the type list) hides the other series for as long as they are chosen, and does not +// delete them. +test('choosing a pie chart and then another type keeps every series of the data', async ({ page }) => { + const errors = []; page.on('pageerror', e => errors.push(e.message)); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/mock.html#/new'); + await expect((await onTab(page, 'home')).getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + await page.getByRole('textbox', { name: 'Document text' }).focus(); + await onTab(page, 'insert'); + await page.locator('#ribbon-panel-insert').getByRole('button', { name: 'Chart', exact: true }).click(); + const chart = page.getByRole('dialog', { name: 'Insert Chart' }); + const types = chart.getByRole('listbox', { name: 'Chart type' }); + const seriesNames = chart.getByRole('textbox', { name: /^Series \d+ name$/ }); + await expect(seriesNames).toHaveCount(3); + await chart.getByRole('textbox', { name: 'Series 2 name' }).fill('Costs'); + await chart.getByRole('textbox', { name: 'Series 1, Category 1' }).fill('7'); + + await types.getByRole('option', { name: 'Pie', exact: true }).click(); + await expect(seriesNames).toHaveCount(1); + await chart.getByRole('textbox', { name: 'Series 1, Category 2' }).fill('9'); + // From the type list with the keyboard: through Doughnut to Scatter. + await types.getByRole('option', { name: 'Pie', exact: true }).focus(); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + await expect(types.getByRole('option', { name: 'Scatter', exact: true })).toHaveAttribute('aria-selected', 'true'); + await types.getByRole('option', { name: 'Clustered column', exact: true }).click(); + await expect(seriesNames).toHaveCount(3); + await expect(chart.getByRole('textbox', { name: 'Series 2 name' })).toHaveValue('Costs'); + + await chart.getByRole('button', { name: 'Insert', exact: true }).click(); + await expect(chart).toBeHidden(); + const series = await page.evaluate(() => { + const m = window.__docs.current.view.engine.model; + const image = m.paragraphs.flatMap(p => p.runs).find(r => r.image?.kind === 'chart').image; + return m.assets[image.assetId].chart.series.map(s => ({ name: s.name, values: s.values ?? s.points?.map(p => p.value) })); + }); + expect(series.map(s => s.name)).toEqual(['Series 1', 'Costs', 'Series 3']); + expect(JSON.stringify(series[0])).toContain('7'); + expect(JSON.stringify(series[0])).toContain('9'); + expect(errors).toEqual([]); +}); From a997aa802e2bb946eb4b720c3190e0caf3f4167e Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:35:07 -0700 Subject: [PATCH 055/186] Documents: Keep a moved picture in place when Layout is confirmed, and rotate and flip from its present state Formatted pictures keep the drawing XML they were given; moves, turns and flips made afterwards live on the model until save. The Layout dialog, Rotate, Flip, Crop and the panes read only the kept XML, so OK in Layout put a nudged or dragged picture back, Rotate Right turned from the old angle and Flip undid an earlier flip. They now read the XML as it would be saved. The dialog also keeps an untouched offset exact instead of writing back its value rounded to 0.01 in. --- src/documents/src/engine/drawing-format.js | 10 ++++ src/documents/src/views/pictures.js | 6 +- src/documents/src/views/size-position.js | 11 +++- .../browser/picture-layout-after-move.spec.js | 56 +++++++++++++++++++ 4 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 src/documents/tests/browser/picture-layout-after-move.spec.js diff --git a/src/documents/src/engine/drawing-format.js b/src/documents/src/engine/drawing-format.js index dd1db095c..12f09643e 100644 --- a/src/documents/src/engine/drawing-format.js +++ b/src/documents/src/engine/drawing-format.js @@ -94,6 +94,16 @@ export function materializeDrawing(pkg, model, image) { return { drawing, parts: found, sourcePart }; } +/** + * A drawing's XML as saving would write it now. A drawing keeps the XML it was opened or formatted with, + * and later edits made on the model (a move, a resize, a turn or flip, alt text) are written over it on + * save, so the kept XML alone describes how it was. Falls back to the kept XML when it cannot be rebuilt. + */ +export function currentDrawingXml(pkg, model, image) { + if (!image?.shell || !pkg || !model || !Object.keys(image).some(key => key.endsWith('Changed') && image[key])) return image?.shell || null; + try { return standalone(materializeDrawing(pkg, model, image).drawing); } catch { return image.shell; } +} + /** Bind every prefix the drawing uses on its root, so it serializes as a standalone fragment. */ function standalone(drawing) { const copy = drawing.cloneNode(true), declared = new Map(); diff --git a/src/documents/src/views/pictures.js b/src/documents/src/views/pictures.js index c4c70abef..a7599af2d 100644 --- a/src/documents/src/views/pictures.js +++ b/src/documents/src/views/pictures.js @@ -13,7 +13,7 @@ import { toast } from '../ui/toast.js'; import { engineApi } from '../lib/engine-api.js'; import { drawShapeThumbnail } from '../ui/shape-controls.js'; import { galleryItems } from './insert-gallery.js'; -import { formatDrawing, drawingKind, drawingParts } from '../engine/drawing-format.js'; +import { formatDrawing, drawingKind, drawingParts, currentDrawingXml } from '../engine/drawing-format.js'; import { describeDrawing, setFill, setOutline, setEffects, set3d, setGeometry, setTransform, setBlipEffects, setCrop, setBodyProperties, setDocProperties, writeFrame, readFrame, themeColors, child, childrenOf, A, WP, R } from '../engine/drawing-xml.js'; import { frameOf, offsetsFor, moveDrawings, alignmentMoves, POSITION_PRESETS, presetPosition } from '../engine/drawing-arrange.js'; @@ -48,12 +48,12 @@ export function createPictures({ editor, pkg, error, onChange = () => {}, base = const ready = () => { const engine = editor(); return engine && !pkg()?.readOnly && !engine.readOnly ? engine : null; }; const selected = () => ready()?.model.selectedImage || null; const described = new WeakMap(); - /** What the selected drawing looks like, read from its XML (cached per drawing object). */ + /** What the selected drawing looks like, read from its XML as it would be saved now (cached per drawing object). */ const describe = image => { if (!image) return null; if (!described.has(image)) { let value = null; - try { value = image.shell ? describeDrawing(image.shell) : { kind: 'picture', name: image.name, description: image.alt, frame: image.anchor ? null : { inline: true }, blip: { luminance: null, grayscale: false, duotone: null, biLevel: null, colorChange: null, alpha: image.opacity ?? 1, layer: null }, effects: { shadow: null, glow: null, softEdge: null, reflection: null }, outline: undefined, geometry: { preset: 'rect' }, transform: { rotation: (image.rotation || 0) / 60000, flipH: !!image.flipH, flipV: !!image.flipV }, threeD: { camera: null, bevel: null }, crop: image.crop || {} }; } catch { value = null; } + try { value = image.shell ? describeDrawing(currentDrawingXml(editor()?.shapePackage?.(), editor()?.model, image)) : { kind: 'picture', name: image.name, description: image.alt, frame: image.anchor ? null : { inline: true }, blip: { luminance: null, grayscale: false, duotone: null, biLevel: null, colorChange: null, alpha: image.opacity ?? 1, layer: null }, effects: { shadow: null, glow: null, softEdge: null, reflection: null }, outline: undefined, geometry: { preset: 'rect' }, transform: { rotation: (image.rotation || 0) / 60000, flipH: !!image.flipH, flipV: !!image.flipV }, threeD: { camera: null, bevel: null }, crop: image.crop || {} }; } catch { value = null; } described.set(image, value); } return described.get(image); diff --git a/src/documents/src/views/size-position.js b/src/documents/src/views/size-position.js index de7cb6c5b..a19b7479c 100644 --- a/src/documents/src/views/size-position.js +++ b/src/documents/src/views/size-position.js @@ -71,6 +71,13 @@ export function openLayoutDialog({ engine, image, describe, tab = 'position', ki const vAlignFrom = choice([['margin', 'Margin'], ['page', 'Page'], ['topMargin', 'Top margin'], ['bottomMargin', 'Bottom margin']], v.align ? v.relativeFrom : 'margin', { 'aria-label': 'Vertical alignment relative to' }); const vOffset = num(inches(inlinePlace ? inlinePlace.y : offsetOf('y')), { min: -22, max: 22, 'aria-label': 'Vertical absolute position (in)' }); const vOffsetFrom = choice([['paragraph', 'Paragraph'], ['line', 'Line'], ['margin', 'Margin'], ['page', 'Page']], v.align ? 'paragraph' : v.relativeFrom, { 'aria-label': 'Vertical position below' }); + // An offset left as shown (rounded to 0.01 in) keeps its exact value, so OK does not nudge the object. + const shownOffsets = inlinePlace ? null : { h: hOffset.value, hFrom: hOffsetFrom.value, x: offsetOf('x'), v: vOffset.value, vFrom: vOffsetFrom.value, y: offsetOf('y') }; + const offsetValue = axis => { + const [input, from] = axis === 'x' ? [hOffset, hOffsetFrom] : [vOffset, vOffsetFrom]; + const kept = shownOffsets && input.value === shownOffsets[axis === 'x' ? 'h' : 'v'] && from.value === shownOffsets[axis === 'x' ? 'hFrom' : 'vFrom']; + return kept ? shownOffsets[axis] : emu(input); + }; const optionBox = (label, checked) => { const input = el('input', { type: 'checkbox', checked, id: id() }); return { input, node: el('label.pic-check', { htmlFor: input.id }, input, label) }; }; const moveWithText = optionBox('Move object with text', !frame.inline ? ['paragraph', 'line'].includes(v.relativeFrom) : true); const lockAnchor = optionBox('Lock anchor', !!frame.locked); @@ -177,8 +184,8 @@ export function openLayoutDialog({ engine, image, describe, tab = 'position', ki } const mode = wrapValue(); const spec = mode === 'inline' ? { inline: true } : { - horizontal: hAlignRadio.input.checked && !hAlignRadio.input.disabled ? { relativeFrom: hAlignFrom.value, align: hAlign.value } : { relativeFrom: hOffsetFrom.value, offset: emu(hOffset) }, - vertical: vAlignRadio.input.checked && !vAlignRadio.input.disabled ? { relativeFrom: vAlignFrom.value, align: vAlign.value } : { relativeFrom: vOffsetFrom.value, offset: emu(vOffset) }, + horizontal: hAlignRadio.input.checked && !hAlignRadio.input.disabled ? { relativeFrom: hAlignFrom.value, align: hAlign.value } : { relativeFrom: hOffsetFrom.value, offset: offsetValue('x') }, + vertical: vAlignRadio.input.checked && !vAlignRadio.input.disabled ? { relativeFrom: vAlignFrom.value, align: vAlign.value } : { relativeFrom: vOffsetFrom.value, offset: offsetValue('y') }, wrap: { type: mode === 'behind' || mode === 'front' ? 'none' : mode, side: sides.find(s => s.input.checked)?.input.value || 'bothSides', distances: { T: mode === 'topAndBottom' ? 0 : emu(distance.T), B: mode === 'topAndBottom' ? 0 : emu(distance.B), L: emu(distance.L), R: emu(distance.R) } }, behind: mode === 'behind', locked: lockAnchor.input.checked, diff --git a/src/documents/tests/browser/picture-layout-after-move.spec.js b/src/documents/tests/browser/picture-layout-after-move.spec.js new file mode 100644 index 000000000..60d5b37f7 --- /dev/null +++ b/src/documents/tests/browser/picture-layout-after-move.spec.js @@ -0,0 +1,56 @@ +import { test, expect } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { onTab } from './helpers/editor-tools.js'; + +// A formatted picture keeps the drawing XML it was given, and later moves, turns and flips are made on +// the model. The Layout dialog, Rotate and Flip read the picture as it is now, so OK in Layout leaves a +// moved picture where it was put, and Rotate turns from the picture's present angle. +const PNG = readFileSync('icon-128.png'); +const input = page => page.getByRole('textbox', { name: 'Document text' }); +const picture = page => page.evaluate(() => { + const i = window.__docs.current.view.engine.model.paragraphs.flatMap(p => p.runs).find(r => r.image).image; + return { x: i.anchor?.x, y: i.anchor?.y, wrap: i.anchor?.wrap, rotation: i.rotation || 0, flipH: !!i.flipH }; +}); + +test('Layout, Rotate and Flip start from where a floating picture was moved and how it was turned', async ({ page }) => { + const errors = []; page.on('pageerror', e => errors.push(e.message)); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/mock.html#/new'); + await expect((await onTab(page, 'home')).getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + await input(page).focus(); + await page.keyboard.type('Pictures sit beside this paragraph of text. '.repeat(8)); + await input(page).press('ControlOrMeta+Home'); + await onTab(page, 'insert'); + await page.getByLabel('Insert picture file').setInputFiles({ name: 'logo.png', mimeType: 'image/png', buffer: PNG }); + const panel = page.locator('#ribbon-panel-picture'); + await expect(panel).toBeVisible(); + await panel.getByRole('button', { name: 'Wrap Text', exact: true }).click(); + await page.getByRole('menuitemradio', { name: 'In Front of Text' }).click(); + await expect.poll(async () => (await picture(page)).wrap).toBe('none'); + const start = await picture(page); + + // Arrow keys move the selected picture 1 px a press. + for (let i = 0; i < 20; i++) await input(page).press('ArrowRight'); + for (let i = 0; i < 10; i++) await input(page).press('ArrowDown'); + const moved = await picture(page); + expect(moved.x - start.x).toBe(20 * 9525); + expect(moved.y - start.y).toBe(10 * 9525); + await panel.getByRole('button', { name: 'Size and Position…', exact: true }).click(); + const layout = page.getByRole('dialog', { name: 'Layout' }); + await expect(layout.getByLabel('Horizontal absolute position (in)')).toHaveValue(String(Math.round(moved.x / 914400 * 100) / 100)); + await layout.getByRole('button', { name: 'OK', exact: true }).click(); + await expect(layout).toBeHidden(); + expect(await picture(page)).toMatchObject({ x: moved.x, y: moved.y }); + + // Turned on the model (as the rotation handle does), then Rotate Right 90° and Flip Horizontal. + await page.evaluate(() => { const e = window.__docs.current.view.engine; e.execute(() => e.model.setImageProperties({ rotation: 30 * 60000 })); }); + await panel.getByRole('button', { name: 'Rotate', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Rotate Right 90°' }).click(); + await expect.poll(async () => (await picture(page)).rotation).toBe(120 * 60000); + expect(await picture(page)).toMatchObject({ x: moved.x, y: moved.y }); + await page.evaluate(() => { const e = window.__docs.current.view.engine; e.execute(() => e.model.setImageProperties({ flipH: true })); }); + await panel.getByRole('button', { name: 'Rotate', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Flip Horizontal' }).click(); + await expect.poll(async () => (await picture(page)).flipH).toBe(false); + expect(errors).toEqual([]); +}); From f35208c75f8a7f18b75afee2d1454dd20b8b5607 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:38:11 -0700 Subject: [PATCH 056/186] Documents: Delete, insert and size only the chosen column or row after Select Column or Select Row beside merged cells A block of cells grows to cover the merged cells it touches, so Select Column under a title row merged across the table became the whole table, and Delete Columns then deleted the table; Select Row beside a cell merged down several rows deleted all of them. Select Row and Select Column now remember the rows or columns chosen, and the row and column commands act on those. --- src/documents/src/engine/model.js | 2 ++ src/documents/src/engine/table-editing.js | 18 ++++++----- src/documents/src/engine/table-selection.js | 15 ++++++++-- src/documents/tests/table-editing.test.js | 33 +++++++++++++++++++++ 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/src/documents/src/engine/model.js b/src/documents/src/engine/model.js index 86570c1d9..dca1949e0 100644 --- a/src/documents/src/engine/model.js +++ b/src/documents/src/engine/model.js @@ -886,6 +886,8 @@ export class TextDocument { const last = this.paragraphs.findLastIndex(p => p.cellId === lastCell.id); this.select(this.paragraphOffset(index), this.paragraphOffset(last) + paragraphLength(this.paragraphs[last])); this.cellBlock = { tableId: table.id, top: normalized.top, bottom: normalized.bottom, left: normalized.left, right: normalized.right }; + // Select Row / Select Column: the rows or columns chosen, inside a block grown by merged cells. + for (const key of ['rows', 'columns']) if (Array.isArray(block[key])) this.cellBlock[key] = [...block[key]]; return true; } /** diff --git a/src/documents/src/engine/table-editing.js b/src/documents/src/engine/table-editing.js index 9d8c67cb4..dcfe13e9e 100644 --- a/src/documents/src/engine/table-editing.js +++ b/src/documents/src/engine/table-editing.js @@ -6,7 +6,7 @@ // layout and painting simply follow the model. import { tableModelInternals as M, paragraph, paragraphText, locate } from './model.js'; import { tableCells, editableCells, verticalMerges, headerRowCount, tableId, fixedColumnWidths, rowGrid, spanWidth, cellContext, documentBlocks } from './tables.js'; -import { cellBlockPlacements, normalizeCellBlock, blockBetween, ownerPlacements } from './table-selection.js'; +import { cellBlockPlacements, normalizeCellBlock, blockBetween, ownerPlacements, blockRowRange, blockColumnRange } from './table-selection.js'; import { resolveStyledParagraph } from './styles.js'; import { repairStructuralBookmarks, normalizeBlockBookmarkScopes } from './bookmarks.js'; import { mergeCellControls } from './block-controls.js'; @@ -107,7 +107,7 @@ function setGrid(table, widths, { fixed = table.layout !== 'autofit' } = {}) { /** Insert rows above or below the selected rows: as many as are selected unless `count` says. */ export function insertRows(model, { above = false, count = null } = {}) { - const block = requireBlock(model), table = block.table; + const chosen = requireBlock(model), table = chosen.table, block = { ...chosen, ...blockRowRange(chosen) }; const n = count ?? block.bottom - block.top + 1; if (!Number.isInteger(n) || n < 1) throw new Error('Choose how many rows to insert.'); if (table.rows.length + n > 1000) throw new Error('A table can have up to 1,000 rows.'); @@ -137,7 +137,7 @@ export function insertRows(model, { above = false, count = null } = {}) { /** Insert columns left or right of the selected columns: as many as are selected unless `count` says. */ export function insertColumns(model, { left = false, count = null, widths: measured } = {}) { - const block = requireBlock(model), table = block.table; + const chosen = requireBlock(model), table = chosen.table, block = { ...chosen, ...blockColumnRange(chosen) }; if (table.rows.some(row => row.gridBefore || row.gridAfter)) throw new Error('Columns cannot be inserted in a table whose rows end unevenly yet.'); const n = count ?? block.right - block.left; if (!Number.isInteger(n) || n < 1) throw new Error('Choose how many columns to insert.'); @@ -198,7 +198,7 @@ export function deleteTable(model) { return removeTable(model, requireBlock(mode /** Delete the selected rows; deleting every row deletes the table. */ export function deleteRows(model) { - const block = requireBlock(model), table = block.table; + const chosen = requireBlock(model), table = chosen.table, block = { ...chosen, ...blockRowRange(chosen) }; if (block.top === 0 && block.bottom === table.rows.length - 1) return removeTable(model, table); const state = M.tableContent(model, table); model.checkpoint(); @@ -224,7 +224,7 @@ export function deleteRows(model) { /** Delete the selected grid columns; deleting every column deletes the table. */ export function deleteColumns(model, { widths: measured } = {}) { - const block = requireBlock(model), table = block.table; + const chosen = requireBlock(model), table = chosen.table, block = { ...chosen, ...blockColumnRange(chosen) }; if (block.left === 0 && block.right === table.grid.length) return removeTable(model, table); if (table.rows.some(row => row.gridBefore || row.gridAfter)) throw new Error('Columns cannot be deleted from a table whose rows end unevenly yet.'); const column = block.left, end = block.right, span = end - column; @@ -583,7 +583,9 @@ export function setSelectedColumnWidth(model, twips, { widths: measured } = {}) const block = requireBlock(model), table = block.table; const widths = columnWidths(model, table, measured); const next = [...widths]; - for (const c of cellsInRow(table.rows[block.top], block.left, block.right)) { + // Select Column sets the columns it chose; otherwise each selected cell of the top row takes the width. + const cells = block.columns ? [blockColumnRange(block)].map(({ left, right }) => ({ column: left, end: right, span: right - left })) : cellsInRow(table.rows[block.top], block.left, block.right); + for (const c of cells) { const current = spanWidth(widths, c.column, c.span); for (let i = c.column; i < c.end; i++) next[i] = widths[i] * twips / current; } @@ -607,8 +609,8 @@ export function setRowsHeight(model, rows, twips, rule = 'atLeast') { model.didChange(); return true; } export function setSelectedRowHeight(model, twips, rule = 'atLeast') { - const block = requireBlock(model); - return setRowsHeight(model, block.table.rows.slice(block.top, block.bottom + 1), twips, rule); + const block = requireBlock(model), { top, bottom } = blockRowRange(block); + return setRowsHeight(model, block.table.rows.slice(top, bottom + 1), twips, rule); } /** Distribute Rows: the selected rows (or every row) share their total height evenly. `heights` are the laid-out row heights in twips. */ diff --git a/src/documents/src/engine/table-selection.js b/src/documents/src/engine/table-selection.js index 4ac575b0f..d4cdb044c 100644 --- a/src/documents/src/engine/table-selection.js +++ b/src/documents/src/engine/table-selection.js @@ -49,15 +49,24 @@ export function blockBetween(table, fromId, toId) { return normalizeCellBlock(table, { tableId: table.id, top: Math.min(a.rowIndex, b.rowIndex), bottom: Math.max(a.endRow, b.endRow), left: Math.min(a.column, b.column), right: Math.max(a.end, b.end) }); } -/** A block for Select Cell / Row / Column / Table around a cell, or around an existing block. */ +/** + * A block for Select Cell / Row / Column / Table around a cell, or around an existing block. A block + * grows to cover the merged cells it touches, so a column under a title row merged across the table + * becomes the whole table; Select Row and Select Column remember the rows or columns chosen + * (`rows` / `columns`, [first, last] and [left, right)) for the commands that act on rows or columns. + */ export function blockFor(table, around, kind) { const all = { tableId: table.id, top: 0, bottom: table.rows.length - 1, left: 0, right: table.grid.length }; if (kind === 'table') return all; - if (kind === 'row') return normalizeCellBlock(table, { ...all, top: around.top, bottom: around.bottom }); - if (kind === 'column') return normalizeCellBlock(table, { ...all, left: around.left, right: around.right }); + if (kind === 'row') { const block = normalizeCellBlock(table, { ...all, top: around.top, bottom: around.bottom }); return block && { ...block, rows: [around.top, around.bottom] }; } + if (kind === 'column') { const block = normalizeCellBlock(table, { ...all, left: around.left, right: around.right }); return block && { ...block, columns: [around.left, around.right] }; } return normalizeCellBlock(table, { tableId: table.id, top: around.top, bottom: around.bottom, left: around.left, right: around.right }); } +/** The rows ([top, bottom]) and grid columns ([left, right)) row and column commands act on. */ +export const blockRowRange = block => block.rows && block.rows[0] >= block.top && block.rows[1] <= block.bottom && block.rows[0] <= block.rows[1] ? { top: block.rows[0], bottom: block.rows[1] } : { top: block.top, bottom: block.bottom }; +export const blockColumnRange = block => block.columns && block.columns[0] >= block.left && block.columns[1] <= block.right && block.columns[0] < block.columns[1] ? { left: block.columns[0], right: block.columns[1] } : { left: block.left, right: block.right }; + export const blockRows = block => Array.from({ length: block.bottom - block.top + 1 }, (_, i) => block.top + i); export const wholeRows = (table, block) => block.left === 0 && block.right === table.grid.length; export const wholeColumns = (table, block) => block.top === 0 && block.bottom === table.rows.length - 1; diff --git a/src/documents/tests/table-editing.test.js b/src/documents/tests/table-editing.test.js index f28821d91..c283d44cf 100644 --- a/src/documents/tests/table-editing.test.js +++ b/src/documents/tests/table-editing.test.js @@ -104,6 +104,39 @@ describe('rows and columns', () => { valid(doc); }); + it('acts on the chosen column or row of Select Column / Select Row beside merged cells, not on the whole table', () => { + // A title row merged across the table: Select Column on the third column covers the whole table as a block. + const select = (doc, kind, r, c) => { at(doc, r, c); const b = T.selectionBlock(doc); doc.setCellBlock(blockFor(b.table, b, kind)); }; + let { doc } = tableDocument(); + block(doc, 0, 0, 0, 3); T.mergeBlock(doc); + select(doc, 'column', 1, 2); + T.deleteColumns(doc); + expect(shape(doc)).toEqual(['a0/a1/a2:2', 'b0 b1', 'c0 c1']); + doc.undo(); + select(doc, 'column', 1, 2); + T.insertColumns(doc, { widths: [3000, 3000, 3000] }); + expect(shape(doc)).toEqual(['a0/a1/a2:3 ', 'b0 b1 b2 ', 'c0 c1 c2 ']); + doc.undo(); + select(doc, 'column', 1, 2); + T.setSelectedColumnWidth(doc, 1440, { widths: [3000, 3000, 3000] }); + expect(doc.tables[0].grid).toEqual([3000, 3000, 1440]); + // A first column merged down three rows: Select Row on the second row. + ({ doc } = tableDocument(4, 2)); + block(doc, 1, 0, 3, 1); T.mergeBlock(doc); + select(doc, 'row', 2, 1); + T.deleteRows(doc); + expect(shape(doc)).toEqual(['a0 a1', 'b0/c0/d0^ b1', 'v d1']); + doc.undo(); + select(doc, 'row', 2, 1); + T.insertRows(doc, {}); + expect(shape(doc)).toEqual(['a0 a1', 'b0/c0/d0^ b1', 'v c1', 'v ', 'v d1']); + doc.undo(); + select(doc, 'row', 2, 1); + T.setSelectedRowHeight(doc, 720); + expect(doc.tables[0].rows.map(r => r.minHeight || 0)).toEqual([0, 0, 720, 0]); + valid(doc); + }); + it('keeps a vertical merge’s content when its first row is deleted', () => { const { doc } = tableDocument(); block(doc, 0, 0, 1, 1); T.mergeBlock(doc); From 66c6cc4f4b0d40ffdb174298681a01ea53e5c348 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:40:13 -0700 Subject: [PATCH 057/186] Documents: Insert an equation after a selected shape or picture instead of replacing it With a shape selected, Insert Equation (gallery or dialog) replaced the shape's anchor character, so the shape disappeared, often far from where the equation appeared. New equations now go after the selected drawing, as new drawings do. --- src/documents/src/views/insert-tab.js | 2 + .../browser/equation-after-drawing.spec.js | 45 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 src/documents/tests/browser/equation-after-drawing.spec.js diff --git a/src/documents/src/views/insert-tab.js b/src/documents/src/views/insert-tab.js index f75ff0abd..67b7f13c5 100644 --- a/src/documents/src/views/insert-tab.js +++ b/src/documents/src/views/insert-tab.js @@ -619,6 +619,8 @@ export function createInsert({ editor, pkg, error, base = {}, docName = () => '' const engine = ready(); if (!engine) return; engine.finishComposition(); const xml = linearToOmml(linear, { display }), text = (ommlToLinear(xml) || linear).replace(/\s+/g, ' ').trim() || 'Equation'; + // A new equation goes after a selected picture or shape, as a new drawing does, rather than replacing it. + afterSelectedDrawing(engine); run(engine, () => { const model = engine.model; if (display) { diff --git a/src/documents/tests/browser/equation-after-drawing.spec.js b/src/documents/tests/browser/equation-after-drawing.spec.js new file mode 100644 index 000000000..f25811605 --- /dev/null +++ b/src/documents/tests/browser/equation-after-drawing.spec.js @@ -0,0 +1,45 @@ +import { test, expect } from '@playwright/test'; +import { onTab } from './helpers/editor-tools.js'; + +// With a shape or picture selected, a new equation goes after it, as a new drawing does, instead of +// replacing it. +const objects = page => page.evaluate(() => window.__docs.current.view.engine.model.paragraphs.flatMap(p => p.runs.filter(r => r.math || r.image).map(r => r.math ? 'equation' : 'drawing'))); + +test('inserting an equation keeps a selected shape', async ({ page }) => { + const errors = []; page.on('pageerror', e => errors.push(e.message)); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/mock.html#/new'); + await expect((await onTab(page, 'home')).getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + const input = page.getByRole('textbox', { name: 'Document text' }); + await input.focus(); + await page.keyboard.type('Shapes sit beside this paragraph of text. '.repeat(4)); + await input.press('ControlOrMeta+Home'); + const insert = page.locator('#ribbon-panel-insert'); + await onTab(page, 'insert'); + await insert.getByRole('button', { name: 'Shapes', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Rectangle', exact: true }).click(); + const box = await page.locator('canvas.canvas-page').first().boundingBox(); + await page.mouse.move(box.x + 420, box.y + 380); await page.mouse.down(); await page.mouse.move(box.x + 540, box.y + 450, { steps: 4 }); await page.mouse.up(); + await expect(page.locator('#ribbon-tab-shapeFormat')).toBeVisible(); + expect(await page.evaluate(() => !!window.__docs.current.view.engine.model.selectedImage)).toBe(true); + + // From the gallery (a displayed equation)… + await onTab(page, 'insert'); + await page.getByRole('button', { name: 'Equation gallery' }).click(); + await page.getByRole('menuitem', { name: /^Pythagorean/ }).click(); + await expect.poll(() => objects(page)).toEqual(['drawing', 'equation']); + + // …and from the dialog (in line), with the shape selected again. + await page.evaluate(() => { + const e = window.__docs.current.view.engine, m = e.model; + let at = 0; + for (const p of m.paragraphs) { for (const r of p.runs) { if (r.image) { m.select(at, at + 1); e.syncInput(); e.paint(); e.onSelection(); return; } at += r.text.length; } at += 1; } + }); + expect(await page.evaluate(() => !!window.__docs.current.view.engine.model.selectedImage)).toBe(true); + await page.keyboard.press(process.platform === 'darwin' ? 'Control+Equal' : 'Alt+Equal'); + const equation = page.getByRole('dialog', { name: 'Equation' }); + await equation.getByRole('textbox', { name: 'Equation in linear format' }).fill('x^2'); + await equation.getByRole('button', { name: 'Insert' }).click(); + await expect.poll(() => objects(page)).toEqual(['drawing', 'equation', 'equation']); + expect(errors).toEqual([]); +}); From fcad20b7e52d6bcf30b3f43b1c8221e8ce7bb18b Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:45:48 -0700 Subject: [PATCH 058/186] Documents: Draw lines and arrows the way they are dragged, and keep slanted lines slanted when formatted Insert > Shapes > Line always made a horizontal line as wide as the drag (a vertical drag left a 4 px stub), and changing a slanted line's outline or size flattened it. A dragged line now runs from the drag's start to its end, straight along an axis when within a few pixels of it, with its arrowhead at the end; shape edits keep a line's height. --- src/documents/src/engine/authored-shapes.js | 7 +-- src/documents/src/engine/shape-editing.js | 5 +- src/documents/src/views/insert-tab.js | 12 +++-- .../tests/browser/drawn-lines.spec.js | 52 +++++++++++++++++++ 4 files changed, 68 insertions(+), 8 deletions(-) create mode 100644 src/documents/tests/browser/drawn-lines.spec.js diff --git a/src/documents/src/engine/authored-shapes.js b/src/documents/src/engine/authored-shapes.js index 9260321b3..5bb56e6a8 100644 --- a/src/documents/src/engine/authored-shapes.js +++ b/src/documents/src/engine/authored-shapes.js @@ -68,14 +68,14 @@ const solid = color => color ? `' : ''; const outline = line ? `${solid(line)}${ends}` : ''; // A text box keeps its paragraphs and body properties as authored; a plain shape has an empty body. const body = text !== undefined ? `${text}${bodyXml || TEXT_BODY}` : ''; - const shape = `` + const shape = `` + `${isLine ? '' : solid(fill)}${outline}${body}`; const docPr = ``, extent = ``; const frame = anchor @@ -97,7 +97,8 @@ export function buildShape(params) { const scene = readShapeScene(wsp, unsupported('pictures'), unsupported('text'), unsupported('picture fills'), unsupported('theme colors'), unsupported('theme fonts'), unsupported('theme styles'), { frame: { width: params.width, height: params.height } }); const asset = { scene, width: params.width / 9525, height: params.height / 9525 }; const image = validateImage({ kind: 'drawing', retainedDrawing: true, assetId: params.assetId, width: params.width, height: params.height, - ...(LINE_PRESETS.has(params.preset) ? { flatLine: { rotation: 0, flipH: false, flipV: false, strokeWidth: scene.outline?.width || 0, ...(scene.outline?.tailEnd ? { markerPen: scene.outline.declaredWidth ?? scene.outline.width, tailEnd: scene.outline.tailEnd } : {}) } } : {}), + // A straight line along one axis is a flat line; a slanted one is drawn as its box's diagonal. + ...(LINE_PRESETS.has(params.preset) && !params.width !== !params.height ? { flatLine: { rotation: 0, flipH: false, flipV: false, strokeWidth: scene.outline?.width || 0, ...(scene.outline?.tailEnd ? { markerPen: scene.outline.declaredWidth ?? scene.outline.width, tailEnd: scene.outline.tailEnd } : {}) } } : {}), effectExtent: { left: 0, top: 0, right: 0, bottom: 0 }, ...(params.anchor ? { anchor: params.anchor } : {}), id: params.id, name: params.name, alt: '', shell }); return { image, asset }; } diff --git a/src/documents/src/engine/shape-editing.js b/src/documents/src/engine/shape-editing.js index 2dac56775..137dd93c4 100644 --- a/src/documents/src/engine/shape-editing.js +++ b/src/documents/src/engine/shape-editing.js @@ -86,8 +86,9 @@ export async function updateShape(editor, patch) { const selected = selectedShape(editor); if (!selected) throw new Error('Select a shape to format it.'); const { image, params } = selected, model = editor.model, revision = editor.session.revision; - const width = patch.width ?? image.width, height = LINE_PRESETS.has(params.preset) ? 0 : patch.height ?? image.height; - if (!(width > 0) || !(height > 0 || LINE_PRESETS.has(params.preset)) || width > 22 * EMU_PER_INCH || height > 22 * EMU_PER_INCH) throw new Error('Shape sizes must be greater than 0 and at most 22 inches.'); + const width = patch.width ?? image.width, height = patch.height ?? image.height, line = LINE_PRESETS.has(params.preset); + // A line may be straight along either axis (one side 0), or slanted. + if (!(line ? width >= 0 && height >= 0 && width + height > 0 : width > 0 && height > 0) || width > 22 * EMU_PER_INCH || height > 22 * EMU_PER_INCH) throw new Error('Shape sizes must be greater than 0 and at most 22 inches.'); // An axis the edit places at an offset no longer follows the alignment or percentage it had. const moved = new Set(['x', 'y'].filter(axis => patch.anchor?.[axis] !== undefined)); const anchor = image.anchor ? { ...image.anchor, ...(patch.anchor || {}) } : undefined; diff --git a/src/documents/src/views/insert-tab.js b/src/documents/src/views/insert-tab.js index 67b7f13c5..37a740220 100644 --- a/src/documents/src/views/insert-tab.js +++ b/src/documents/src/views/insert-tab.js @@ -242,7 +242,9 @@ export function createInsert({ editor, pkg, error, base = {}, docName = () => '' const canvas = start.canvas, box = canvas.getBoundingClientRect(), zoom = engine.zoom || 1, r = rectOf(start, e), dragged = r.width > 4 || r.height > 4; const position = engine.point(start.event, false), page = Number(canvas.dataset.page); const px = v => Math.round(v / zoom * 9525); - const result = { page, position, dragged, x: px(r.left - box.left), y: px(r.top - box.top), width: px(r.width), height: px(r.height) }; + // Which way the drag went: a line runs from where it started to where it ended. + const result = { page, position, dragged, x: px(r.left - box.left), y: px(r.top - box.top), width: px(r.width), height: px(r.height), flipH: e.clientX < start.clientX, flipV: e.clientY < start.clientY, + straight: { x: r.width <= 4, y: r.height <= 4 } }; cancelDrawing(); done(result); }; const key = e => { if (e.key === 'Escape' && !isComposing(e)) { e.preventDefault(); e.stopPropagation(); cancelDrawing(); engine.focus(); } }; @@ -255,9 +257,13 @@ export function createInsert({ editor, pkg, error, base = {}, docName = () => '' const [, name, , w, h] = entry, line = LINE_PRESETS.has(preset), open = UNFILLED_PRESETS.has(preset); return insertDrawing((engine, id, order) => { if (rect) { engine.model.select(rect.position); } - const width = rect?.dragged ? Math.max(9525 * 4, rect.width) : Math.round(w * EMU_PER_INCH), height = line ? 0 : rect?.dragged ? Math.max(9525 * 4, rect.height) : Math.round(h * EMU_PER_INCH); + // A dragged line goes from the drag's start to its end; one within a few pixels of an axis is straight along it. + const drawnLine = line && rect?.dragged; + const width = drawnLine ? (rect.straight.x ? 0 : rect.width) : rect?.dragged ? Math.max(9525 * 4, rect.width) : Math.round(w * EMU_PER_INCH); + const height = drawnLine ? (rect.straight.y ? 0 : rect.height) : line ? 0 : rect?.dragged ? Math.max(9525 * 4, rect.height) : Math.round(h * EMU_PER_INCH); + const flips = drawnLine ? { flipH: !!width && rect.flipH, flipV: !!height && rect.flipV } : {}; const anchor = rect ? { horizontal: 'page', vertical: 'page', x: rect.x, y: rect.y, wrap: 'none', behind: false, order } : { horizontal: 'column', vertical: 'paragraph', x: 0, y: 0, wrap: 'none', behind: false, order }; - return sceneFragment(engine, shapeDrawingXml({ id, name: `${name} ${id}`, preset, width, height, fill: line || open ? null : DEFAULT_SHAPE_STYLE.fill, line: line || open ? DEFAULT_SHAPE_STYLE.fill : DEFAULT_SHAPE_STYLE.line, lineWidth: DEFAULT_SHAPE_STYLE.lineWidth, anchor })); + return sceneFragment(engine, shapeDrawingXml({ id, name: `${name} ${id}`, preset, width, height, fill: line || open ? null : DEFAULT_SHAPE_STYLE.fill, line: line || open ? DEFAULT_SHAPE_STYLE.fill : DEFAULT_SHAPE_STYLE.line, lineWidth: DEFAULT_SHAPE_STYLE.lineWidth, anchor, ...flips })); }); } function openShapes(anchor, event) { diff --git a/src/documents/tests/browser/drawn-lines.spec.js b/src/documents/tests/browser/drawn-lines.spec.js new file mode 100644 index 000000000..3e6875d04 --- /dev/null +++ b/src/documents/tests/browser/drawn-lines.spec.js @@ -0,0 +1,52 @@ +import { test, expect } from '@playwright/test'; +import { onTab } from './helpers/editor-tools.js'; + +// Insert → Shapes → Line draws from where the drag starts to where it ends: slanted, vertical or +// horizontal; and a slanted line stays slanted when its outline is changed. +const line = page => page.evaluate(() => { + const image = window.__docs.current.view.engine.model.selectedImage; + const ext = /]*)>/.exec(image.shell)?.[1] || ''; + return { width: image.width, height: image.height, ext: ext && [Number(ext[1]), Number(ext[2])], flipH: /flipH="1"/.test(xfrm), flipV: /flipV="1"/.test(xfrm), weight: / { + const errors = []; page.on('pageerror', e => errors.push(e.message)); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/mock.html#/new'); + await expect((await onTab(page, 'home')).getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + await page.getByRole('textbox', { name: 'Document text' }).focus(); + const zoom = await page.evaluate(() => window.__docs.current.view.engine.zoom || 1), emu = px => Math.round(px / zoom * 9525); + + // Down and to the right: the line's box, unflipped. + let drawn = await drawLine(page, [300, 300], [420, 380]); + expect(drawn).toMatchObject({ flipH: false, flipV: false }); + expect(Math.abs(drawn.width - emu(120))).toBeLessThan(9525 * 2); + expect(Math.abs(drawn.height - emu(80))).toBeLessThan(9525 * 2); + // A heavier outline keeps it slanted. + await onTab(page, 'shapeFormat'); + await page.getByRole('button', { name: 'Shape Outline', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Weight' }).click(); + await page.getByRole('menuitemradio', { name: '3 pt', exact: true }).click(); + await expect.poll(async () => (await line(page)).weight).toBe('38100'); + expect(await line(page)).toMatchObject({ width: drawn.width, height: drawn.height }); + + // Up and to the right: flipped vertically. + drawn = await drawLine(page, [300, 600], [420, 520]); + expect(drawn).toMatchObject({ flipH: false, flipV: true }); + expect(drawn.height).toBeGreaterThan(emu(70)); + // Straight down: a vertical line. + drawn = await drawLine(page, [600, 300], [601, 420]); + expect(drawn.width).toBe(0); + expect(drawn.height).toBeGreaterThan(emu(110)); + expect(errors).toEqual([]); +}); From f03f5de50ff2266c34bc60d32dff7605c7cd11e8 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:47:35 -0700 Subject: [PATCH 059/186] Documents: Keep a paragraph between two tables after Convert Text to Table or moving a table under another Converting the text right after a table, or dropping a table on the paragraph right after another, left the two tables touching. Saved that way they read back as one table. An empty paragraph now keeps them apart, as Insert Table already does. --- src/documents/src/engine/table-editing.js | 29 ++++++++++++++++++++--- src/documents/tests/table-editing.test.js | 24 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/documents/src/engine/table-editing.js b/src/documents/src/engine/table-editing.js index dcfe13e9e..741991cf0 100644 --- a/src/documents/src/engine/table-editing.js +++ b/src/documents/src/engine/table-editing.js @@ -985,6 +985,25 @@ function applyAutofit(table, autofit) { for (const cell of tableCells(table)) cell.width = { type: 'auto', value: 0 }; } +/** + * Whether the paragraph before `index` lies in a table at the level of `parentId` (null: the body), so a + * table placed at `index` would touch it: two adjacent `w:tbl` elements read as one table. + */ +function tableJustBefore(model, index, parentId) { + for (let cellId = model.paragraphs[index - 1]?.cellId || null; cellId && cellId !== parentId;) { + const { table } = cellContext(model.tables, cellId); + if ((table.parentCellId || null) === parentId) return true; + cellId = table.parentCellId; + } + return false; +} +/** An empty paragraph to keep two tables apart, formatted like `source`, in the parent cell if any. */ +function separatorLike(model, source, parentId) { + const p = { ...copy(source), id: paragraph().id, runs: [] }; + for (const key of ['list', 'bookmarks', 'fieldMarkers', 'styleChanged', 'mark', 'sectionEnd']) delete p[key]; + return resolveStyledParagraph(p, model.styles, model.numbering); +} + /** Convert Text to Table: the selected paragraphs become rows (or, separated by paragraphs, cells). */ export function convertTextToTable(model, { separator = 'tab', other, columns = null, autofit = 'fixed', availableWidth } = {}) { const char = separatorChar(separator, other), sources = model.selectedParagraphs; @@ -1019,7 +1038,9 @@ export function convertTextToTable(model, { separator = 'tab', other, columns = // A table needs a paragraph after it; the last converted paragraph's formatting supplies one if none follows. const after = model.paragraphs[last + 1]; const tail = after && (after.cellId || null) === parentId ? [] : [(() => { const p = { ...copy(sources.at(-1)), id: paragraph().id, runs: [] }; delete p.list; return resolveStyledParagraph(p, styles, model.numbering); })()]; - model.paragraphs.splice(first, last - first + 1, ...content, ...tail); + // Text right after a table keeps an empty paragraph between that table and the new one. + const head = tableJustBefore(model, first, parentId) ? [separatorLike(model, sources[0], parentId)] : []; + model.paragraphs.splice(first, last - first + 1, ...head, ...content, ...tail); model.tables.push(table); model.selectCell(table.rows[0].cells[0].id); model.didChange(); return true; @@ -1190,8 +1211,10 @@ export function moveTable(model, tableId, beforeId) { if (model.paragraphs[target].cellId) throw new Error('Drop the table between paragraphs outside tables.'); if (table.floating) throw new Error('Floating tables keep their position. Set Text wrapping to None to move one in the text.'); model.checkpoint(); - const moved = model.paragraphs.splice(state.start, state.count); - model.paragraphs.splice(model.paragraphs.findIndex(p => p.id === beforeId), 0, ...moved); + const moved = model.paragraphs.splice(state.start, state.count), at = model.paragraphs.findIndex(p => p.id === beforeId); + // Dropped right under another table: an empty paragraph keeps them two tables. + const head = tableJustBefore(model, at, null) ? [separatorLike(model, model.paragraphs[at], null)] : []; + model.paragraphs.splice(at, 0, ...head, ...moved); repairStructuralBookmarks(model.paragraphs, state.bookmarks, model.tables, table.id); normalizeBlockBookmarkScopes(model.paragraphs, model.tables); model.selectCell(editableCells(table)[0].id); diff --git a/src/documents/tests/table-editing.test.js b/src/documents/tests/table-editing.test.js index c283d44cf..4a73dcf24 100644 --- a/src/documents/tests/table-editing.test.js +++ b/src/documents/tests/table-editing.test.js @@ -288,6 +288,30 @@ describe('table commands', () => { expect(doc.tables[0].rows.map(row => !!row.header)).toEqual([true, false, false]); }); + it('keeps a paragraph between tables when text right after a table is converted, or a table is moved under another', async () => { + const { pkg, doc } = tableDocument(2, 2); + // The paragraph after the table: "xy" becomes a second table, after an empty paragraph. + doc.select(doc.text.length); doc.replace('x\ty'); + doc.select(doc.text.lastIndexOf('x'), doc.text.length); + T.convertTextToTable(doc, { separator: 'tab' }); + expect(documentBlocks(doc.paragraphs, doc.tables).map(b => b.type)).toEqual(['table', 'paragraph', 'table', 'paragraph']); + const { xml, reopened } = await roundTrip(pkg, doc); + expect(xml).not.toMatch(/<\/w:tbl>/); + expect(reopened.tables).toHaveLength(2); + doc.undo(); + expect(doc.tables).toHaveLength(1); + // A third table dropped on the paragraph right after the first. + const { doc: two } = tableDocument(2, 2, ['Top']); + two.select(0); two.insertTable(1, 1, 9000); + expect(documentBlocks(two.paragraphs, two.tables).map(b => b.type)).toEqual(['table', 'paragraph', 'table', 'paragraph']); + const [first, second] = [two.tables.find(t => t.rows.length === 1), two.tables.find(t => t.rows.length === 2)]; + const after = two.paragraphs.at(-1); + expect(T.moveTable(two, first.id, after.id)).toBe(true); + expect(documentBlocks(two.paragraphs, two.tables).map(b => b.type)).toEqual(['paragraph', 'table', 'paragraph', 'table', 'paragraph']); + expect(two.tables.indexOf(second)).toBeGreaterThanOrEqual(0); + valid(two); + }); + it('converts text to a table and back', async () => { const pkg = createDocx([paragraph('Name\tQty'), paragraph('Pens\t2'), paragraph('Ink\t1\textra'), paragraph('After')], undefined, undefined, undefined, [], {}, WRITING_STYLES); const doc = open(pkg); From 5fa613895bf361229e31a33c97d582005271a430 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:49:29 -0700 Subject: [PATCH 060/186] Documents: Leave tracked deletions out of table formulas and sorting Formulas, Sort and the Sort dialog's column names read every run of a cell, deleted ones included: with Track Changes on, retyping 10 as 7 made =SUM(ABOVE) over it and a 5 give 112, and a cell changed from 'date' to 'apple' sorted as 'dateapple'. Cells are now read as they read once the changes are accepted. --- src/documents/src/engine/table-editing.js | 3 ++- src/documents/src/engine/table-formula.js | 6 ++++-- src/documents/src/views/table-tools.js | 4 ++-- src/documents/tests/table-editing.test.js | 13 +++++++++++++ src/documents/tests/table-formula.test.js | 12 ++++++++++++ 5 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/documents/src/engine/table-editing.js b/src/documents/src/engine/table-editing.js index 741991cf0..c5651b542 100644 --- a/src/documents/src/engine/table-editing.js +++ b/src/documents/src/engine/table-editing.js @@ -12,6 +12,7 @@ import { repairStructuralBookmarks, normalizeBlockBookmarkScopes } from './bookm import { mergeCellControls } from './block-controls.js'; import { MAX_DOCUMENT_CHARACTERS, DOCUMENT_CHARACTER_LIMIT_MESSAGE, xmlAttributeText } from './text-limits.js'; import { checkedCellAlignment } from './cell-alignment.js'; +import { keptText } from './table-formula.js'; import { checkedCellBorder, resolveCellBorders } from './cell-borders.js'; import { resolveCellMargins } from './cell-margins.js'; @@ -896,7 +897,7 @@ export function sortTable(model, { keys, header = false, caseSensitive = false } for (const key of keys) if (!Number.isInteger(key.column) || key.column < 0 || key.column >= table.grid.length || !['text', 'number', 'date'].includes(key.type)) throw new Error('Choose a column and type for each sort key.'); const textOf = (row, column) => { const cell = rowGrid(row).find(c => c.column <= column && c.end > column)?.cell; - return cell ? model.paragraphs.filter(p => p.cellId === cell.id).map(paragraphText).join(' ').replace(//g, '').trim() : ''; + return cell ? model.paragraphs.filter(p => p.cellId === cell.id).map(keptText).join(' ').replace(//g, '').trim() : ''; }; const collator = new Intl.Collator(undefined, { sensitivity: caseSensitive ? 'case' : 'base', numeric: false }); const rows = table.rows.slice(first, last + 1).map((row, index) => ({ row, index, values: keys.map(key => { diff --git a/src/documents/src/engine/table-formula.js b/src/documents/src/engine/table-formula.js index 366b821d5..acb4cf8d9 100644 --- a/src/documents/src/engine/table-formula.js +++ b/src/documents/src/engine/table-formula.js @@ -105,9 +105,11 @@ export const columnName = column => { let name = ''; for (let n = column + 1; n /* ---- Evaluation ---- */ -/** The text a cell shows (its own paragraphs, not nested tables'). */ +/** A paragraph's text with its tracked deletions left out, as it reads once changes are accepted. */ +export const keptText = p => p.runs.some(run => run.revision?.type === 'del') ? p.runs.filter(run => run.revision?.type !== 'del').map(run => run.text).join('') : paragraphText(p); +/** The text a cell shows (its own paragraphs, not nested tables'), without tracked deletions. */ function cellText(paragraphs, cell) { - return paragraphs.filter(p => p.cellId === cell.id).map(paragraphText).join(' ').replace(/\ufffc/g, ''); + return paragraphs.filter(p => p.cellId === cell.id).map(keptText).join(' ').replace(/\ufffc/g, ''); } function cellAt(table, row, column) { const r = table.rows[row]; diff --git a/src/documents/src/views/table-tools.js b/src/documents/src/views/table-tools.js index b025ca1f9..261d0a53f 100644 --- a/src/documents/src/views/table-tools.js +++ b/src/documents/src/views/table-tools.js @@ -10,7 +10,7 @@ import { TEXT_COLORS } from './text-color.js'; import * as T from '../engine/table-editing.js'; import { blockFor, cellBlockPlacements, wholeRows, wholeColumns } from '../engine/table-selection.js'; import { applyTableStyle, setTableLook, clearTableStyle, saveTableStyle, tableLook, availableTableStyles, tableStyleSpec, newTableStyleId, specFromTable, DEFAULT_LOOK } from '../engine/table-styles.js'; -import { insertFormula, evaluateFormula, suggestedFormula, FORMULA_FUNCTIONS, NUMBER_FORMATS, buildInstruction } from '../engine/table-formula.js'; +import { insertFormula, evaluateFormula, suggestedFormula, FORMULA_FUNCTIONS, NUMBER_FORMATS, buildInstruction, keptText } from '../engine/table-formula.js'; import { QUICK_TABLES, insertQuickTable } from '../engine/quick-tables.js'; import { headerRowCount, rowGrid, verticalMerges } from '../engine/tables.js'; import { locate } from '../engine/model.js'; @@ -249,7 +249,7 @@ export function createTableTools({ editor, pkg, error = () => {}, onChange = () function sortDialog() { const engine = ready(); const b = block(); if (!engine || !b) return; engine.finishComposition(); - const table = b.table, firstRow = table.rows[0], text = cell => engine.model.paragraphs.filter(p => p.cellId === cell.id).map(p => p.runs.map(r => r.text).join('')).join(' ').trim(); + const table = b.table, firstRow = table.rows[0], text = cell => engine.model.paragraphs.filter(p => p.cellId === cell.id).map(keptText).join(' ').replace(//g, '').trim(); const columns = rowGrid(firstRow).map((entry, i) => ({ column: entry.column, label: `Column ${i + 1}`, header: text(entry.cell) || `Column ${i + 1}` })); openSortDialog({ columns, header: headerRowCount(table) > 0 || !!table.conditionalStyle?.settings.look.firstRow && !!table.styleId }, choice => { let failure; engine.execute(() => T.sortTable(engine.model, choice), e => { failure = e; }, undefined, true); if (failure) throw failure; onChange(); return true; }, { onClose: closeFocus }); diff --git a/src/documents/tests/table-editing.test.js b/src/documents/tests/table-editing.test.js index 4a73dcf24..2438879a6 100644 --- a/src/documents/tests/table-editing.test.js +++ b/src/documents/tests/table-editing.test.js @@ -270,6 +270,19 @@ describe('table commands', () => { expect(T.sortDate('3/4/2024')).toBe(Date.UTC(2024, 2, 4)); }); + it('sorts cells by the text they keep once tracked changes are accepted', () => { + const { doc } = tableDocument(3, 1); + const fill = (r, text) => { at(doc, r, 0); const p = doc.paragraphs.find(x => x.cellId === cellAt(doc, r, 0).id), s = doc.paragraphOffset(doc.paragraphs.indexOf(p)); doc.select(s, s + paragraphText(p).length); doc.replace(text); }; + fill(0, 'cherry'); fill(1, 'banana'); fill(2, 'date'); + // A reviewer replaces "date" with "apple": "date" stays as a tracked deletion. + doc.trackChanges = true; doc.revisionAuthor = 'Reviewer'; + fill(2, 'apple'); + doc.trackChanges = false; + at(doc, 0, 0); + T.sortTable(doc, { keys: [{ column: 0, type: 'text' }] }); + expect(shape(doc)).toEqual(['dateapple', 'banana', 'cherry']); + }); + it('sorting a repeated header row into the body without "Header row" leaves the repeat on the top row', async () => { const { pkg, doc } = tableDocument(3, 1); const fill = (r, text) => { at(doc, r, 0); doc.select(doc.selection.focus, doc.selection.focus + 2); doc.replace(text); }; diff --git a/src/documents/tests/table-formula.test.js b/src/documents/tests/table-formula.test.js index 1b9fd645a..f604ea610 100644 --- a/src/documents/tests/table-formula.test.js +++ b/src/documents/tests/table-formula.test.js @@ -92,3 +92,15 @@ it('inserts an = field with its result, updates it with Update Fields and round- expect(textAt(reopened, 3, 1)).toBe('25.00'); expect(bodyFieldRanges(reopened.paragraphs)[0].field.type).toBe('='); }); + +it('reads cells as they read with tracked changes accepted: deleted text does not count', () => { + const { doc } = sheet(); + // A reviewer retypes Q1's 10 as 7 with Track Changes on: "10" stays as a deletion beside "7". + const p = doc.paragraphs.find(x => x.cellId === cellAt(doc, 1, 1).id), start = doc.paragraphOffset(doc.paragraphs.indexOf(p)); + doc.trackChanges = true; doc.revisionAuthor = 'Reviewer'; + doc.select(start, start + 2); doc.replace('7'); + doc.trackChanges = false; + expect(p.runs.some(r => r.revision?.type === 'del')).toBe(true); + expect(evaluateFormula(doc, '=SUM(ABOVE)', cellAt(doc, 3, 1).id)).toBe('12'); + expect(evaluateFormula(doc, '=B2*2', cellAt(doc, 3, 1).id)).toBe('14'); +}); From 2124cee589900f546ac284aca100105b3236b857 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:49:56 -0700 Subject: [PATCH 061/186] Documents: Round halves away from zero in table formulas ROUND used Math.round on the scaled binary value, so ROUND(-2.5,0) gave -2 and ROUND(1.005,2) gave 1, unlike the \# number format and Word. It now shifts the decimal point in the number's decimal digits and rounds halves away from zero. --- src/documents/src/engine/table-formula.js | 11 ++++++++++- src/documents/tests/table-formula.test.js | 6 ++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/documents/src/engine/table-formula.js b/src/documents/src/engine/table-formula.js index acb4cf8d9..86cd691f4 100644 --- a/src/documents/src/engine/table-formula.js +++ b/src/documents/src/engine/table-formula.js @@ -105,6 +105,15 @@ export const columnName = column => { let name = ''; for (let n = column + 1; n /* ---- Evaluation ---- */ +// Move the decimal point by `places` in the number's own decimal digits, so 1.005 shifts to 100.5, not 100.49999999999999. +const shiftDecimal = (value, places) => { const [mantissa, exponent = '0'] = String(value).split('e'); return Number(`${mantissa}e${Number(exponent) + places}`); }; +/** ROUND: to `places` decimals (tens, hundreds… when negative), halves away from zero. */ +function roundAway(value, places) { + if (!Number.isFinite(value)) return value; + const rounded = shiftDecimal(Math.round(shiftDecimal(Math.abs(value), places)), -places); + return value < 0 && rounded ? -rounded : rounded; +} + /** A paragraph's text with its tracked deletions left out, as it reads once changes are accepted. */ export const keptText = p => p.runs.some(run => run.revision?.type === 'del') ? p.runs.filter(run => run.revision?.type !== 'del').map(run => run.text).join('') : paragraphText(p); /** The text a cell shows (its own paragraphs, not nested tables'), without tracked deletions. */ @@ -200,7 +209,7 @@ function evaluate(node, context) { case 'SIGN': need(1); return Math.sign(value(0)); case 'NOT': need(1); return +!value(0); case 'MOD': { need(2); const b = value(1); if (b === 0) throw new Error('!Zero Divide'); return value(0) % b; } - case 'ROUND': { need(2); const f = 10 ** Math.trunc(value(1)); return Math.round(value(0) * f) / f; } + case 'ROUND': { need(2); return roundAway(value(0), Math.trunc(value(1))); } case 'AND': need(2); return +(!!value(0) && !!value(1)); case 'OR': need(2); return +(!!value(0) || !!value(1)); case 'IF': need(3); return value(0) ? value(1) : value(2); diff --git a/src/documents/tests/table-formula.test.js b/src/documents/tests/table-formula.test.js index f604ea610..8e2496155 100644 --- a/src/documents/tests/table-formula.test.js +++ b/src/documents/tests/table-formula.test.js @@ -104,3 +104,9 @@ it('reads cells as they read with tracked changes accepted: deleted text does no expect(evaluateFormula(doc, '=SUM(ABOVE)', cellAt(doc, 3, 1).id)).toBe('12'); expect(evaluateFormula(doc, '=B2*2', cellAt(doc, 3, 1).id)).toBe('14'); }); + +it('ROUND rounds halves away from zero, at the decimal places typed', () => { + const { doc } = sheet(); + const at = cellAt(doc, 3, 1).id; + expect(['=ROUND(-2.5,0)', '=ROUND(2.5,0)', '=ROUND(1.005,2)', '=ROUND(-1.005,2)', '=ROUND(1234.5,-2)', '=ROUND(0.125,2)'].map(f => evaluateFormula(doc, f, at))).toEqual(['-3', '3', '1.01', '-1.01', '1200', '0.13']); +}); From cff214eed6ad73b0ea92c116a23a9763472f14c0 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:50:47 -0700 Subject: [PATCH 062/186] Documents: Count a cell like "100 (20)" as 100 in table formulas, not -100 Any parenthesized number anywhere in a cell made its leading number negative. Only parentheses around the leading number itself, as in the accounting style "(30)", do now. --- src/documents/src/engine/table-formula.js | 7 ++++--- src/documents/tests/table-formula.test.js | 4 ++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/documents/src/engine/table-formula.js b/src/documents/src/engine/table-formula.js index 86cd691f4..ee0742315 100644 --- a/src/documents/src/engine/table-formula.js +++ b/src/documents/src/engine/table-formula.js @@ -17,11 +17,12 @@ export const NUMBER_FORMATS = Object.freeze(['0', '0.00', '#,##0', '#,##0.00', ' /** The leading number of a cell's text: currency, grouping, percent and accounting negatives allowed. */ export function cellNumber(text) { // The number must lead the cell's text: "12 boxes" counts, "Q1" does not. - const match = /^\s*()([-+−]?)\s*\(?\s*[$€£¥]?\s*(\d[\d,]*(?:\.\d+)?|\.\d+)/.exec(text); + // Parentheses make it negative (accounting style) only when they enclose that leading number: "(30)", not "100 (20)". + const match = /^\s*()([-+−]?)\s*(\(?)\s*[$€£¥]?\s*(\d[\d,]*(?:\.\d+)?|\.\d+)\s*(\)?)/.exec(text); if (!match) return null; - const value = Number(match[3].replace(/,/g, '')); + const value = Number(match[4].replace(/,/g, '')); if (!Number.isFinite(value)) return null; - const negative = match[2] === '-' || match[2] === '−' || /\(\s*[$€£¥]?\s*[\d.,]+\s*\)/.test(text); + const negative = match[2] === '-' || match[2] === '−' || !!match[3] && !!match[5]; return negative ? -value : value; } diff --git a/src/documents/tests/table-formula.test.js b/src/documents/tests/table-formula.test.js index 8e2496155..308fdd72d 100644 --- a/src/documents/tests/table-formula.test.js +++ b/src/documents/tests/table-formula.test.js @@ -110,3 +110,7 @@ it('ROUND rounds halves away from zero, at the decimal places typed', () => { const at = cellAt(doc, 3, 1).id; expect(['=ROUND(-2.5,0)', '=ROUND(2.5,0)', '=ROUND(1.005,2)', '=ROUND(-1.005,2)', '=ROUND(1234.5,-2)', '=ROUND(0.125,2)'].map(f => evaluateFormula(doc, f, at))).toEqual(['-3', '3', '1.01', '-1.01', '1200', '0.13']); }); + +it('reads only parentheses around a cell’s leading number as negative', () => { + expect([cellNumber('100 (20)'), cellNumber('12 boxes (spare)'), cellNumber('(30)'), cellNumber('( $1,200.50 )'), cellNumber('(30) returned'), cellNumber('(30')]).toEqual([100, 12, -30, -1200.5, -30, 30]); +}); From 71116eae8c5e65146193a46a566a5249fb72b987 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:51:45 -0700 Subject: [PATCH 063/186] Documents: Refuse to sort table rows that a comment or bookmark spans, instead of breaking it Sorting moved each row with its markers, so a comment from one row into another could end up ending before it started; the comment highlighted other text and was saved with commentRangeEnd before commentRangeStart. Sort now explains why it cannot run, as sorting paragraphs already does; ranges within one row move with it. --- src/documents/src/engine/table-editing.js | 9 +++++++++ src/documents/tests/table-editing.test.js | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/documents/src/engine/table-editing.js b/src/documents/src/engine/table-editing.js index c5651b542..f7cbdae53 100644 --- a/src/documents/src/engine/table-editing.js +++ b/src/documents/src/engine/table-editing.js @@ -915,6 +915,15 @@ export function sortTable(model, { keys, header = false, caseSensitive = false } }); if (sorted.every((item, i) => item.index === i)) return false; const state = M.tableContent(model, table); + // Reordered, a comment or bookmark from one of these rows into another could end before it starts. + const rowOf = new Map(), edges = new Map(); + rows.forEach(({ row }, i) => { for (const cell of row.cells) for (const p of state.byCell.get(cell.id) || []) rowOf.set(p, i); }); + for (const [p, i] of rowOf) for (const marker of p.bookmarks || []) { + if ((marker.edge !== 'start' && marker.edge !== 'end') || marker.tableBoundary || marker.columnRange || marker.blockScope) continue; + if (!edges.has(marker.id)) edges.set(marker.id, {}); + edges.get(marker.id)[marker.edge] = i; + } + if ([...edges.values()].some(({ start, end }) => start !== undefined && end !== undefined && start !== end)) throw new Error('A comment or bookmark runs from one of these rows into another, so they cannot be sorted.'); model.checkpoint(); table.rows.splice(first, sorted.length, ...sorted.map(item => item.row)); // Repeating as a header row belongs to the table's top rows: a row sorted into the body stops repeating and diff --git a/src/documents/tests/table-editing.test.js b/src/documents/tests/table-editing.test.js index 2438879a6..e5f3d7ba7 100644 --- a/src/documents/tests/table-editing.test.js +++ b/src/documents/tests/table-editing.test.js @@ -283,6 +283,25 @@ describe('table commands', () => { expect(shape(doc)).toEqual(['dateapple', 'banana', 'cherry']); }); + it('does not sort rows when a comment or bookmark runs from one sorted row into another', () => { + const { doc } = tableDocument(3, 1); + const fill = (r, text) => { at(doc, r, 0); const p = doc.paragraphs.find(x => x.cellId === cellAt(doc, r, 0).id), s = doc.paragraphOffset(doc.paragraphs.indexOf(p)); doc.select(s, s + paragraphText(p).length); doc.replace(text); }; + fill(0, 'cherry'); fill(1, 'banana'); fill(2, 'apple'); + const s = doc.text.indexOf('cherry') + 2, e = doc.text.indexOf('banana') + 3; + doc.addComment({ author: 'x', text: 'note', range: [s, e] }); + at(doc, 0, 0); + expect(() => T.sortTable(doc, { keys: [{ column: 0, type: 'text' }] })).toThrow(/comment/); + expect(shape(doc)).toEqual(['cherry', 'banana', 'apple']); + // A range within one row moves with it. + const { doc: other } = tableDocument(3, 1); + const set = (r, text) => { at(other, r, 0); const p = other.paragraphs.find(x => x.cellId === cellAt(other, r, 0).id), st = other.paragraphOffset(other.paragraphs.indexOf(p)); other.select(st, st + paragraphText(p).length); other.replace(text); }; + set(0, 'cherry'); set(1, 'banana'); set(2, 'apple'); + other.addComment({ author: 'x', text: 'note', range: [other.text.indexOf('banana'), other.text.indexOf('banana') + 3] }); + at(other, 0, 0); + expect(T.sortTable(other, { keys: [{ column: 0, type: 'text' }] })).toBe(true); + expect(shape(other)).toEqual(['apple', 'banana', 'cherry']); + }); + it('sorting a repeated header row into the body without "Header row" leaves the repeat on the top row', async () => { const { pkg, doc } = tableDocument(3, 1); const fill = (r, text) => { at(doc, r, 0); doc.select(doc.selection.focus, doc.selection.focus + 2); doc.replace(text); }; From 18931db6b22b10fc7108c8ff6a3af3af9e229348 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:53:30 -0700 Subject: [PATCH 064/186] Documents: Offer every column in Sort when the table's first row is a merged title The Sort dialog listed only the cells of the first row, so under a title merged across the table it offered just Column 1. It now lists each column a cell starts at in any row, named from a cell just that wide. --- src/documents/src/views/table-tools.js | 10 ++++-- .../tests/browser/table-sort-columns.spec.js | 34 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 src/documents/tests/browser/table-sort-columns.spec.js diff --git a/src/documents/src/views/table-tools.js b/src/documents/src/views/table-tools.js index 261d0a53f..bffb0c1f3 100644 --- a/src/documents/src/views/table-tools.js +++ b/src/documents/src/views/table-tools.js @@ -249,8 +249,14 @@ export function createTableTools({ editor, pkg, error = () => {}, onChange = () function sortDialog() { const engine = ready(); const b = block(); if (!engine || !b) return; engine.finishComposition(); - const table = b.table, firstRow = table.rows[0], text = cell => engine.model.paragraphs.filter(p => p.cellId === cell.id).map(keptText).join(' ').replace(//g, '').trim(); - const columns = rowGrid(firstRow).map((entry, i) => ({ column: entry.column, label: `Column ${i + 1}`, header: text(entry.cell) || `Column ${i + 1}` })); + const table = b.table, text = cell => engine.model.paragraphs.filter(p => p.cellId === cell.id).map(keptText).join(' ').replace(//g, '').trim(); + // Every column a cell starts at in some row, so a title merged across the first row hides none; each is + // named from the first row with a cell just that wide there. + const grids = table.rows.map(rowGrid), starts = [...new Set(grids.flat().map(entry => entry.column))].sort((x, y) => x - y); + const columns = starts.map((column, i) => { + const end = starts[i + 1] ?? table.grid.length, entry = grids.flat().find(e => e.column === column && e.end === end) || grids.flat().find(e => e.column === column); + return { column, label: `Column ${i + 1}`, header: text(entry.cell) || `Column ${i + 1}` }; + }); openSortDialog({ columns, header: headerRowCount(table) > 0 || !!table.conditionalStyle?.settings.look.firstRow && !!table.styleId }, choice => { let failure; engine.execute(() => T.sortTable(engine.model, choice), e => { failure = e; }, undefined, true); if (failure) throw failure; onChange(); return true; }, { onClose: closeFocus }); } diff --git a/src/documents/tests/browser/table-sort-columns.spec.js b/src/documents/tests/browser/table-sort-columns.spec.js new file mode 100644 index 000000000..84e593100 --- /dev/null +++ b/src/documents/tests/browser/table-sort-columns.spec.js @@ -0,0 +1,34 @@ +import { test, expect } from '@playwright/test'; +import { onTab } from './helpers/editor-tools.js'; + +// The Sort dialog offers every column of the table, also when its first row is a title merged across it. +const input = page => page.getByRole('textbox', { name: 'Document text' }); +const texts = page => page.evaluate(() => { const m = window.__docs.current.view.engine.model; return m.tables[0].rows.map(r => r.cells.map(c => m.paragraphs.filter(p => p.cellId === c.id).map(p => p.runs.map(x => x.text).join('')).join('/'))); }); + +test('Sort offers each column under a merged title row and sorts by the one chosen', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/mock.html#/new'); + await expect((await onTab(page, 'home')).getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + await input(page).focus(); + await (await onTab(page, 'insert')).getByRole('button', { name: 'Insert table', exact: true }).click(); + await page.getByRole('menuitem', { name: '3 columns, 4 rows' }).click(); + for (const [i, value] of ['Stock', '', '', 'Item', 'Shelf', 'Count', 'Pens', 'B', '7', 'Ink', 'A', '30'].entries()) { if (i) await input(page).press('Tab'); if (value) await page.keyboard.type(value); } + // Merge the title row across the table. + await page.evaluate(() => { const e = window.__docs.current.view.engine, t = e.model.tables[0]; e.model.setCellBlock({ tableId: t.id, top: 0, bottom: 0, left: 0, right: 3 }); e.syncInput(); e.paint(); e.onSelection(); e.focus(); }); + await onTab(page, 'tableLayout'); + await page.locator('.ribbon-card').getByRole('button', { name: 'Merge Cells', exact: true }).click(); + await page.evaluate(() => { const e = window.__docs.current.view.engine; e.model.selectCell(e.model.tables[0].rows[2].cells[0].id); e.syncInput(); e.paint(); e.onSelection(); e.focus(); }); + // Rows 2 and 3 only: select them, then Sort by the shelf column. + await page.evaluate(() => { const e = window.__docs.current.view.engine, t = e.model.tables[0]; e.model.setCellBlock({ tableId: t.id, top: 2, bottom: 3, left: 0, right: 3 }); e.syncInput(); e.paint(); e.onSelection(); e.focus(); }); + await page.locator('.ribbon-card').getByRole('button', { name: 'Sort', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: 'Sort' }); + const by = dialog.getByRole('combobox', { name: 'Sort by column' }); + await expect(by.locator('option')).toHaveText(['Column 1', 'Column 2', 'Column 3']); + await dialog.getByRole('radio', { name: 'A header row' }).check(); + await expect(by.locator('option')).toHaveText(['Item', 'Shelf', 'Count']); + await dialog.getByRole('radio', { name: 'No header row' }).check(); + await by.selectOption({ label: 'Column 2' }); + await dialog.getByRole('button', { name: 'OK', exact: true }).click(); + await expect(dialog).toBeHidden(); + expect(await texts(page)).toEqual([['Stock'], ['Item', 'Shelf', 'Count'], ['Ink', 'A', '30'], ['Pens', 'B', '7']]); +}); From 2d40f0457b37fe3b03ed5b17c0394f2ad5ce5be8 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:54:15 -0700 Subject: [PATCH 065/186] Documents: Keep columns lined up when converting a table with cells merged down to text Convert Table to Text skipped the rows a merged cell continues into, so the next cell's text moved into the first column (b1 under a0's column), and converting back made a misaligned table. Those places now become empty fields. --- src/documents/src/engine/table-editing.js | 7 ++++--- src/documents/tests/table-editing.test.js | 11 +++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/documents/src/engine/table-editing.js b/src/documents/src/engine/table-editing.js index f7cbdae53..2ca5a5508 100644 --- a/src/documents/src/engine/table-editing.js +++ b/src/documents/src/engine/table-editing.js @@ -1075,9 +1075,10 @@ export function convertTableToText(model, { separator = 'tab', other } = {}) { }; const output = []; for (const row of table.rows) { - const cells = row.cells.filter(cell => owners.get(cell.id).cell === cell).map(cell => state.byCell.get(cell.id) || []); + // A cell merged down from above holds its place in the row as an empty field, so the columns stay lined up. + const cells = row.cells.map(cell => owners.get(cell.id).cell === cell ? state.byCell.get(cell.id) || [] : []); if (char === null) { for (const content of cells) output.push(...content.map(scope)); continue; } - const p = scope(cells[0]?.[0] || paragraph()); + const p = scope(cells.find(content => content.length)?.[0] || paragraph()); p.runs = []; delete p.fieldMarkers; delete p.bookmarks; let at = 0; const append = (source, text) => { @@ -1087,7 +1088,7 @@ export function convertTableToText(model, { separator = 'tab', other } = {}) { if (text) { // The separator takes the formatting of the text before it, as plain text: not a copy of an // object run (a picture, equation, note reference, field or control) nor of a change mark. - const style = p.runs.at(-1) || { ...(cells[0]?.[0]?.runs[0] || {}), properties: '', overrides: {} }, run = { ...copy(style), text }; + const style = p.runs.at(-1) || { ...(cells.find(content => content.length)?.[0]?.runs[0] || {}), properties: '', overrides: {} }, run = { ...copy(style), text }; for (const key of OBJECT_RUN_KEYS) delete run[key]; p.runs.push(run); at += text.length; } diff --git a/src/documents/tests/table-editing.test.js b/src/documents/tests/table-editing.test.js index e5f3d7ba7..611d2d889 100644 --- a/src/documents/tests/table-editing.test.js +++ b/src/documents/tests/table-editing.test.js @@ -359,6 +359,17 @@ describe('table commands', () => { expect(doc.paragraphs.map(paragraphText)).toEqual(['Name,Qty,', 'Pens,2,', 'Ink,1,extra', 'After']); }); + it('converts a table with cells merged down to text with the columns still lined up', () => { + const { doc } = tableDocument(); + block(doc, 0, 0, 1, 1); T.mergeBlock(doc); + expect(shape(doc)).toEqual(['a0/b0^ a1 a2', 'v b1 b2', 'c0 c1 c2']); + at(doc, 0, 1); T.convertTableToText(doc, { separator: 'tab' }); + expect(doc.paragraphs.map(paragraphText).slice(0, 3)).toEqual(['a0\vb0\ta1\ta2', '\tb1\tb2', 'c0\tc1\tc2']); + // And back: the same three columns. + doc.select(0, doc.text.indexOf('c2') + 2); + expect(T.textTableShape(doc, { separator: 'tab' })).toEqual({ rows: 3, columns: 3 }); + }); + it('converts text inside a cell into a nested table', async () => { const { pkg, doc } = tableDocument(1, 1); const cell = doc.tables[0].rows[0].cells[0]; From 60e9e8e127860ab0a422639734cc8b2c8e9f3d52 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:57:22 -0700 Subject: [PATCH 066/186] Documents: Set a column's width with the Width box also in a table fitted to the window In a table sized as a share of the window (AutoFit Window, or many imported tables), the columns were stretched back to fill the window after the Width box set one, so a column set to 1 in came out wider. Setting a width now gives such a table the total of its columns as its width. --- src/documents/src/engine/table-editing.js | 7 +++++-- src/documents/tests/browser/table-tools.spec.js | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/documents/src/engine/table-editing.js b/src/documents/src/engine/table-editing.js index 2ca5a5508..0f2e9b9a7 100644 --- a/src/documents/src/engine/table-editing.js +++ b/src/documents/src/engine/table-editing.js @@ -569,11 +569,13 @@ export function splitTable(model) { /* ---- Sizes ---- */ /** Set the grid of a table to these widths (twips). Cell preferences follow; `layout` may fix the table. */ -export function setColumnWidths(model, table, widths, { layout } = {}) { +export function setColumnWidths(model, table, widths, { layout, absolute = false } = {}) { if (widths.length !== table.grid.length || widths.some(w => !Number.isInteger(w) || w < 1 || w > MAX_WIDTH)) throw new Error('Enter a positive width for each column, up to 22 inches.'); if (sum(widths) > MAX_WIDTH) throw new Error('The table would be wider than 22 inches.'); model.checkpoint(); if (layout && table.layout !== layout) { table.layout = layout; table.layoutChanged = true; } + // `absolute`: a table sized as a share of the window becomes as wide as its columns. + if (absolute && table.width?.type === 'pct') { table.width = { type: 'dxa', value: sum(widths) }; table.widthChanged = true; } setGrid(table, [...widths], { fixed: true }); model.didChange(); return true; } @@ -590,7 +592,8 @@ export function setSelectedColumnWidth(model, twips, { widths: measured } = {}) const current = spanWidth(widths, c.column, c.span); for (let i = c.column; i < c.end; i++) next[i] = widths[i] * twips / current; } - return setColumnWidths(model, table, integerWidths(next, Math.round(sum(next))), { layout: 'fixed' }); + // A table sized as a share of the window would stretch the columns back to fill it: it takes their total. + return setColumnWidths(model, table, integerWidths(next, Math.round(sum(next))), { layout: 'fixed', absolute: true }); } /** diff --git a/src/documents/tests/browser/table-tools.spec.js b/src/documents/tests/browser/table-tools.spec.js index ac92a6186..b0a67f906 100644 --- a/src/documents/tests/browser/table-tools.spec.js +++ b/src/documents/tests/browser/table-tools.spec.js @@ -212,6 +212,21 @@ test('the Width spinner shows and sets the width of each selected column', async await expect(width).toHaveValue('3'); }); +test('the Width spinner sets a column of a table fitted to the window to that width', async ({ page }) => { + await newTable(page, 3, 2, ['a', 'b', 'c']); + await goToCell(page, 0, 0); + await onTab(page, 'tableLayout'); + await ribbon(page).getByRole('button', { name: 'AutoFit', exact: true }).click(); + await page.getByRole('menuitem', { name: 'AutoFit Window' }).click(); + await expect.poll(() => page.evaluate(() => window.__docs.current.view.engine.model.tables[0].width.type)).toBe('pct'); + const width = page.getByRole('spinbutton', { name: 'Width (in)' }); + await width.fill('1'); await width.press('Enter'); + // The column is laid out 1 in wide (the table is no longer stretched to the window to fill it out again). + const laidOut = () => page.evaluate(() => { const e = window.__docs.current.view.engine; return e.layout.tableWidths[e.model.tables[0].id].map(w => Math.round(w * 15)); }); + await expect.poll(async () => (await laidOut())[0]).toBe(1440); + await expect(width).toHaveValue('1'); +}); + test('the Table menu and context menu offer the table commands', async ({ page }) => { await newTable(page, 2, 2, ['a', 'b', 'c', 'd']); await goToCell(page, 0, 0); From 95b39a4a97bb91bf2a10e9981e6f05ccab39f6aa Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:00:51 -0700 Subject: [PATCH 067/186] Documents: Undo a column width typed in the Width box with one Undo Enter applied the width, and the box applied it again when focus returned to the page; the second, identical change still took an undo step, so the first Undo seemed to do nothing. Setting the widths a table already has is now no change. --- src/documents/src/engine/table-editing.js | 7 +++++++ src/documents/tests/browser/table-tools.spec.js | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/src/documents/src/engine/table-editing.js b/src/documents/src/engine/table-editing.js index 0f2e9b9a7..f9377e6ae 100644 --- a/src/documents/src/engine/table-editing.js +++ b/src/documents/src/engine/table-editing.js @@ -572,6 +572,13 @@ export function splitTable(model) { export function setColumnWidths(model, table, widths, { layout, absolute = false } = {}) { if (widths.length !== table.grid.length || widths.some(w => !Number.isInteger(w) || w < 1 || w > MAX_WIDTH)) throw new Error('Enter a positive width for each column, up to 22 inches.'); if (sum(widths) > MAX_WIDTH) throw new Error('The table would be wider than 22 inches.'); + // Widths the table already has (a size box applying its value again as it loses focus) are no change. + const dxa = (value, width) => value?.type === 'dxa' && value.value === width; + const unchanged = widths.every((w, i) => w === table.grid[i]) && (!layout || table.layout === layout) + && (table.width?.type === 'pct' ? !absolute : dxa(table.width, Math.min(MAX_WIDTH, sum(widths)))) + && table.rows.every(row => rowGrid(row).every(item => dxa(item.cell.width, spanWidth(widths, item.column, item.span))) + && (!row.gridBefore || dxa(row.widthBefore, spanWidth(widths, 0, row.gridBefore))) && (!row.gridAfter || dxa(row.widthAfter, spanWidth(widths, widths.length - row.gridAfter, row.gridAfter)))); + if (unchanged) return false; model.checkpoint(); if (layout && table.layout !== layout) { table.layout = layout; table.layoutChanged = true; } // `absolute`: a table sized as a share of the window becomes as wide as its columns. diff --git a/src/documents/tests/browser/table-tools.spec.js b/src/documents/tests/browser/table-tools.spec.js index b0a67f906..09a24a953 100644 --- a/src/documents/tests/browser/table-tools.spec.js +++ b/src/documents/tests/browser/table-tools.spec.js @@ -206,6 +206,11 @@ test('the Width spinner shows and sets the width of each selected column', async await expect(width).toHaveValue(String(Number((before[0] / 1440).toFixed(2)))); await width.fill('1.5'); await width.press('Enter'); await expect.poll(grid).toEqual([2160, 2160, before[2]]); + // One Undo takes it back (the box applying its value again as it loses focus is no second step). + await page.getByRole('button', { name: 'Undo', exact: true }).first().click(); + await expect.poll(grid).toEqual(before); + await page.getByRole('button', { name: 'Redo', exact: true }).first().click(); + await expect.poll(grid).toEqual([2160, 2160, before[2]]); // A merged cell shows the width it covers. await page.evaluate(() => { const e = window.__docs.current.view.engine, m = e.model, t = m.tables[0]; m.setCellBlock({ tableId: t.id, top: 0, bottom: 0, left: 0, right: 2 }); }); await (await onTab(page, 'tableLayout')).getByRole('button', { name: 'Merge Cells', exact: true }).click(); From 6ac4ed16672f8e4c495452318b6f4c9ed43f6e26 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:04:21 -0700 Subject: [PATCH 068/186] Documents: Leave no gaps around a turned picture put back in line with text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A floating picture turned 90° reserves its turned bounds above and below its frame. Put back In Line with Text, where the frame itself turns, it kept that room too, so a 6.5 by 1.6 in picture took an 11 in line. The reserved room is now worked out again when a picture goes in line or starts to float. --- src/documents/src/engine/drawing-format.js | 5 ++++- src/documents/tests/rotated-picture-bounds.test.js | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/documents/src/engine/drawing-format.js b/src/documents/src/engine/drawing-format.js index 12f09643e..2cc4a6677 100644 --- a/src/documents/src/engine/drawing-format.js +++ b/src/documents/src/engine/drawing-format.js @@ -184,9 +184,12 @@ export function reserveRotatedBounds(drawing, image) { const extent = xml.child(frame, WP, 'extent'), transform = xml.child(properties, A, 'xfrm'); const next = { ...image, width: Number(extent?.getAttribute('cx')), height: Number(extent?.getAttribute('cy')), rotation: normalizedRotation(Number(transform?.getAttribute('rot') || 0)) }; delete next.effectExtent; + // An edit may also take the picture in line or let it float: an inline frame turns with the picture + // (a quarter turn swaps its sides), while a floating one keeps its sides and reserves the turned bounds. + if (frame.localName === 'inline') delete next.anchor; else next.anchor = image.anchor || {}; if (!(next.width > 0 && next.height > 0)) return; - if (next.width === image.width && next.height === image.height && next.rotation === normalizedRotation(image.rotation)) return; const oldRequired = rotationOutsets(image), nextRequired = rotationOutsets(next), oldExtent = imageExtents(image); + if (next.width === image.width && next.height === image.height && next.rotation === normalizedRotation(image.rotation) && CROP_SIDES.every(side => oldRequired[side] === nextRequired[side])) return; let effect = xml.child(frame, WP, 'effectExtent'); if (!effect) { effect = xml.make(frame.ownerDocument, WP, 'effectExtent'); extent.after(effect); } for (const [side, key] of CROP_SIDES.map((side, i) => [side, 'ltrb'[i]])) effect.setAttribute(key, String(nextRequired[side] + Math.max(0, oldExtent[side] - oldRequired[side]))); diff --git a/src/documents/tests/rotated-picture-bounds.test.js b/src/documents/tests/rotated-picture-bounds.test.js index e74fcebdb..7cc87825f 100644 --- a/src/documents/tests/rotated-picture-bounds.test.js +++ b/src/documents/tests/rotated-picture-bounds.test.js @@ -22,3 +22,15 @@ it('keeps room a frame reserved beyond its rotation, such as for a shadow', () = reserveRotatedBounds(turned, image); expect(effect(turned)).toBe('l=177347 t=177347 r=187347 b=187347'); }); + +it('gives back the room a turned floating picture reserved when it goes in line, where its frame turns with it', () => { + // 2 × 0.5 in, turned 90°: floating, its frame reserves 0.75 in above and below; in line, the frame itself is 0.5 × 2 in. + const image = { width: 1828800, height: 457200, rotation: 5400000, anchor: { wrap: 'none' }, effectExtent: { left: 0, top: 685800, right: 0, bottom: 685800 } }; + const inline = new DOMParser().parseFromString(``, 'application/xml').documentElement; + reserveRotatedBounds(inline, image); + expect(effect(inline)).toBe('l=0 t=0 r=0 b=0'); + // And back: floating again, it reserves the room once more. + const floating = new DOMParser().parseFromString(`00`, 'application/xml').documentElement; + reserveRotatedBounds(floating, { ...image, anchor: undefined, effectExtent: { left: 0, top: 0, right: 0, bottom: 0 } }); + expect(effect(floating)).toBe('l=0 t=685800 r=0 b=685800'); +}); From 680ebabdeeceae3a8dc34fe5555a56be3b4cca7c Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:05:54 -0700 Subject: [PATCH 069/186] Documents: Stop Edit Equation from adding brackets around a function's argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An equation such as lim_(x→0) f(x) or max_i f(x) opened in Edit Equation as lim_(x→0) (f(x)), and pressing Update without a change put visible parentheses around f(x). An argument made of terms ending in a bracketed group, a script or a nested function is now written as it was typed. --- src/documents/src/engine/math-linear.js | 8 ++++++-- src/documents/tests/math-linear.test.js | 8 ++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/documents/src/engine/math-linear.js b/src/documents/src/engine/math-linear.js index d69a09791..0ec9d74a4 100644 --- a/src/documents/src/engine/math-linear.js +++ b/src/documents/src/engine/math-linear.js @@ -515,8 +515,12 @@ function editableStructure(node) { fname = `${FUNCS.has(word) ? word : `\\operatorname{${word}}`}${mathChild(only, 'sub') ? `_${scriptArgument(mathChild(only, 'sub'))}` : ''}${mathChild(only, 'sup') ? `^${scriptArgument(mathChild(only, 'sup'))}` : ''}`; } else { const word = runText(name); fname = FUNCS.has(word) ? word : `\\operatorname{${word}}`; } // The argument is the terms up to the next space or operator, or one bracketed group. - const items = editableItems(e), simple = items.length && items.every(item => item.kind === 'run' || ['sSup', 'sSub', 'sSubSup', 'acc', 'bar'].includes(item.kind)) && items.length === 1 || items.length > 1 && items.every(item => item.kind === 'run'); - const argument = items.length === 1 && items[0].kind === 'd' ? items[0].text : simple ? items.map(item => item.text).join('') : bracket(editableSequence(e)); + // Unbracketed, the parser takes terms up to a space or operator and stops after a bracketed group, so + // runs may lead up to one last term: a run, script, accent or bar, a bracketed group ("f(x)") or a + // nested function ("sin x" in "lim sin x"), which takes the rest itself. + const items = editableItems(e), last = items.at(-1); + const simple = items.length && items.slice(0, -1).every(item => item.kind === 'run') && (['run', 'sSup', 'sSub', 'sSubSup', 'acc', 'bar', 'd'].includes(last.kind) || last.kind === 'func' && items.length === 1); + const argument = simple ? items.map(item => item.text).join('') : bracket(editableSequence(e)); return `${fname} ${argument}`; } case 'acc': { diff --git a/src/documents/tests/math-linear.test.js b/src/documents/tests/math-linear.test.js index 4db89e97f..ab94c0e41 100644 --- a/src/documents/tests/math-linear.test.js +++ b/src/documents/tests/math-linear.test.js @@ -189,6 +189,14 @@ describe('editing an existing equation', () => { expect(linearToOmml(editable), `${linear} → ${editable}`).toBe(xml); return editable; }; + it('writes a function’s argument without brackets the argument did not have', () => { + expect(same('lim_(x→0) f(x)')).toBe('lim_(x→0) f(x)'); + expect(same('max_i f(x)')).toBe('max_i f(x)'); + expect(same('lim_(x→0) sin x')).toBe('lim_(x→0) sin x'); + same('lim f(x)/g(x)'); + same('sin x^2'); same('sin (a+b)'); same('sin^2 x'); + }); + it('writes every structure in a form that builds it again', () => { for (const { linear } of EQUATION_GALLERY) same(linear); // Binomials, matrices and equation arrays stay themselves; roots keep their degree. From fbecf2745bb8ec43bd4af85b2af6c83777e50c5a Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:08:36 -0700 Subject: [PATCH 070/186] Documents: Keep the lines of a displayed equation apart through Edit Equation A displayed equation of several lines (as Word makes with Enter inside it) opened in Edit Equation as one line with spaces between, and Update joined it into a single equation (a=b and c=d became a=bc=d). Each line now has its own line in the dialog and becomes its own m:oMath again; Shift+Enter starts a new one. --- src/documents/docs/conformance.md | 4 ++- src/documents/src/engine/math-linear.js | 13 ++++++--- .../tests/browser/equation-edit.spec.js | 29 +++++++++++++++++++ src/documents/tests/math-linear.test.js | 13 +++++++++ 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/documents/docs/conformance.md b/src/documents/docs/conformance.md index ff044577e..34ea17db1 100644 --- a/src/documents/docs/conformance.md +++ b/src/documents/docs/conformance.md @@ -23934,7 +23934,9 @@ claim of full OOXML conformance or of layout fidelity with other consumers. `m:eqArr`, with child elements in schema order. Equations are atomic objects: the canvas shows their linear text; the editor dialog previews them as MathML. Edit Equation shows a selected equation in the input syntax, so that updating it unchanged builds the same structures (binomials - as `m:type="noBar"` fractions, `m:m`, `m:eqArr`, `m:deg`, multi-letter accents and bars). Gaps: + as `m:type="noBar"` fractions, `m:m`, `m:eqArr`, `m:deg`, multi-letter accents and bars). A + displayed equation of several lines (an `m:oMathPara` holding several `m:oMath`, §22.1.2.78) + shows one line of input for each and is written back the same way; Shift+Enter starts a line. Gaps: alignment points in equation arrays, delimiter separators, nested superscripts, two-dimensional canvas rendering; an edited equation loses structures the input syntax has no form for (`m:box`, `m:borderBox`, `m:groupChr`, `m:phant`, `m:sPre`, `m:limUpp` outside a function name, an n-ary diff --git a/src/documents/src/engine/math-linear.js b/src/documents/src/engine/math-linear.js index 0ec9d74a4..9fe8b7a28 100644 --- a/src/documents/src/engine/math-linear.js +++ b/src/documents/src/engine/math-linear.js @@ -382,8 +382,11 @@ function element(n) { /** Office Math markup for linear-format input; display wraps it in m:oMathPara. */ export function linearToOmml(input, { display = false } = {}) { - const ns = `xmlns:m="${MATH}" xmlns:w="${W}"`, body = omml(parseLinearMath(input).body); - return display ? `${body}` : `${body}`; + const ns = `xmlns:m="${MATH}" xmlns:w="${W}"`; + if (!display) return `${omml(parseLinearMath(input).body)}`; + // A displayed equation may have several lines (§22.1.2.78 oMathPara): one m:oMath for each line typed. + const lines = String(input).split(/\r?\n/).filter(line => line.trim()); + return `${(lines.length ? lines : ['']).map(line => `${omml(parseLinearMath(line).body)}`).join('')}`; } /** Linear text for existing Office Math markup, to edit it in the input box. */ @@ -486,7 +489,7 @@ function editableNode(node) { function editableStructure(node) { const e = mathChild(node, 'e'); switch (node.localName) { - case 'oMathPara': return mathKids(node, 'oMath').map(editableNode).join(' '); + case 'oMathPara': return mathKids(node, 'oMath').map(editableNode).join(LINE_BREAK); case 'oMath': return editableSequence(node); case 'f': return `${fractionPart(mathChild(node, 'num'))}${mathVal(mathChild(node, 'fPr'), 'type') === 'noBar' ? '¦' : '/'}${fractionPart(mathChild(node, 'den'))}`; case 'sSup': return `${base(e)}^${scriptArgument(mathChild(node, 'sup'))}`; @@ -543,9 +546,11 @@ function editableStructure(node) { } /** Linear text for existing Office Math markup that builds the same equation again when typed back. */ +// Lines of a displayed equation, kept apart from the line breaks inside its text (which read as spaces). +const LINE_BREAK = '\u2028'; export function ommlToEditableLinear(xml) { const root = editableRoot(xml); - return root ? editableNode(root).replace(/[\r\n]+/g, ' ').trim() : ''; + return root ? editableNode(root).replace(/[\r\n]+/g, ' ').split(LINE_BREAK).map(line => line.trim()).filter(Boolean).join('\n') : ''; } export const EQUATION_GALLERY = [ diff --git a/src/documents/tests/browser/equation-edit.spec.js b/src/documents/tests/browser/equation-edit.spec.js index 48560e4c7..98ab56bf9 100644 --- a/src/documents/tests/browser/equation-edit.spec.js +++ b/src/documents/tests/browser/equation-edit.spec.js @@ -40,3 +40,32 @@ test('updating an equation unchanged keeps binomials, matrices and root degrees' } expect(errors).toEqual([]); }); + +test('a displayed equation of two lines keeps its lines through Edit Equation', async ({ page }) => { + const errors = []; page.on('pageerror', e => errors.push(e.message)); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/mock.html#/new'); + await expect((await onTab(page, 'home')).getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + await page.getByRole('textbox', { name: 'Document text' }).focus(); + await page.keyboard.press(process.platform === 'darwin' ? 'Control+Equal' : 'Alt+Equal'); + const dialog = page.getByRole('dialog', { name: 'Equation' }), box = dialog.getByRole('textbox', { name: 'Equation in linear format' }); + await box.fill('x+y=3'); + await box.press('Shift+Enter'); + await box.pressSequentially('x-y=1'); + await dialog.getByRole('checkbox', { name: 'Display on its own line' }).check(); + await expect(dialog.locator('.insert-equation-preview math mspace[linebreak="newline"]')).toHaveCount(1); + await dialog.getByRole('button', { name: 'Insert' }).click(); + await expect(dialog).toBeHidden(); + const [xml] = await equations(page); + expect(xml.match(//g)).toHaveLength(2); + await selectEquation(page, 0); + await onTab(page, 'insert'); + await page.getByRole('button', { name: 'Equation gallery' }).click(); + await page.getByRole('menuitem', { name: 'Edit Equation…' }).click(); + const edit = page.getByRole('dialog', { name: 'Edit Equation' }); + await expect(edit.getByRole('textbox', { name: 'Equation in linear format' })).toHaveValue('x+y=3\nx−y=1'); + await edit.getByRole('button', { name: 'Update' }).click(); + await expect(edit).toBeHidden(); + expect((await equations(page))[0]).toBe(xml); + expect(errors).toEqual([]); +}); diff --git a/src/documents/tests/math-linear.test.js b/src/documents/tests/math-linear.test.js index ab94c0e41..0e4caaf39 100644 --- a/src/documents/tests/math-linear.test.js +++ b/src/documents/tests/math-linear.test.js @@ -189,6 +189,19 @@ describe('editing an existing equation', () => { expect(linearToOmml(editable), `${linear} → ${editable}`).toBe(xml); return editable; }; + it('keeps each line of a displayed equation of several lines on its own line', () => { + const M = 'xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"'; + const word = `a=bc=d`; + const editable = ommlToEditableLinear(word); + expect(editable).toBe('a=b\nc=d'); + const rebuilt = linearToOmml(editable, { display: true }); + expect(rebuilt.match(//g)).toHaveLength(2); + expect(ommlToEditableLinear(rebuilt)).toBe(editable); + // Typed in the dialog with Shift+Enter, too; in line with text, lines run together as before. + expect(linearToOmml('x=1\n\ny=2', { display: true }).match(//g)).toHaveLength(2); + expect(linearToOmml('x=1\ny=2')).toBe(linearToOmml('x=1 y=2')); + }); + it('writes a function’s argument without brackets the argument did not have', () => { expect(same('lim_(x→0) f(x)')).toBe('lim_(x→0) f(x)'); expect(same('max_i f(x)')).toBe('max_i f(x)'); From 875cb080161dc99e89023140511de14f621cbc63 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:10:56 -0700 Subject: [PATCH 071/186] Documents: Keep a drawing canvas when all of its ink is erased or deleted Erasing the last strokes in a drawing canvas, deleting them with the lasso or turning them into shapes removed the whole canvas, as for loose ink. The canvas now stays as an empty frame to draw in. --- src/documents/src/views/draw-tab.js | 8 +++++--- src/documents/tests/browser/draw.spec.js | 26 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/documents/src/views/draw-tab.js b/src/documents/src/views/draw-tab.js index 8fba48d99..f34931cf8 100644 --- a/src/documents/src/views/draw-tab.js +++ b/src/documents/src/views/draw-tab.js @@ -152,11 +152,13 @@ export function createDraw({ editor, pkg, error, onChange = () => {}, commands = async function finishErase() { const job = erasing; erasing = null; if (!job?.edited.size) return; - const ops = [...job.edited].map(([image, strokes]) => (strokes.length ? { replace: image, strokes } : { remove: image })); + const ops = [...job.edited].map(([image, strokes]) => (strokes.length ? { replace: image, strokes } : emptied(image))); await committer.commit(ops); for (const image of job.edited.keys()) hidden.delete(image); surface.setScene(job.page, null); repaint(); } + // A drawing left without strokes goes, except a drawing canvas, which stays as an empty frame to draw in. + const emptied = image => inkOf(image)?.canvas ? { replace: image, strokes: [] } : { remove: image }; /** Erase all ink on the current page or everywhere. */ async function eraseAll(scope = 'document') { const engine = ready(); if (!engine) return; @@ -219,7 +221,7 @@ export function createDraw({ editor, pkg, error, onChange = () => {}, commands = } async function deleteSelection() { if (!selected) return; - const edited = selectionEdit(s => s), ops = [...edited].map(([image, list]) => { const keep = list.filter(e => !e.selected).map(e => e.stroke); return keep.length ? { replace: image, strokes: keep } : { remove: image }; }); + const edited = selectionEdit(s => s), ops = [...edited].map(([image, list]) => { const keep = list.filter(e => !e.selected).map(e => e.stroke); return keep.length ? { replace: image, strokes: keep } : emptied(image); }); setSelection(null); await committer.commit(ops); } @@ -237,7 +239,7 @@ export function createDraw({ editor, pkg, error, onChange = () => {}, commands = for (const [image, list] of groups) { const keep = list.filter(e => !converted.has(e.stroke)).map(e => e.stroke); if (keep.length === list.length) continue; - ops.push(keep.length ? { replace: image, strokes: keep } : { remove: image }); + ops.push(keep.length ? { replace: image, strokes: keep } : emptied(image)); } for (const { shape, from } of shapes) ops.push(shapeOp(page, shape, { color: from[0].tool === 'highlighter' ? '000000' : from[0].color, width: from[0].width })); await committer.commit(ops); diff --git a/src/documents/tests/browser/draw.spec.js b/src/documents/tests/browser/draw.spec.js index d78a20478..442f38248 100644 --- a/src/documents/tests/browser/draw.spec.js +++ b/src/documents/tests/browser/draw.spec.js @@ -297,3 +297,29 @@ test('draws with touch on a phone-width window', async ({ browser }, info) => { expect(errors).toEqual([]); await context.close(); }); + +test('erasing or deleting every stroke in a drawing canvas keeps the empty canvas', async ({ page }) => { + test.setTimeout(120000); + const { input, errors, ribbon } = await start(page, { text: 'Canvas below.' }); + // The canvas is the document's only object once the session has put the ink into it. + const canvases = () => objects(input); + await ribbon.getByRole('button', { name: 'Drawing Canvas', exact: true }).click(); + await expect.poll(canvases).toBe(1); + const box = await page.locator('canvas.canvas-page').first().boundingBox(); + await draw(page, box, wave(150, 200, 20, 10, 25)); + await ribbon.getByRole('button', { name: 'Eraser', exact: true }).click(); + await expect.poll(() => objects(input)).toBe(1); + // The stroke eraser takes the only stroke: the canvas stays, empty. + await draw(page, box, [[150 + 60, 160], [150 + 60, 240]]); + await expect.poll(() => inkAt(page, 150 + 60, 200 + Math.sin(2) * 25)).toBe(0); + await expect.poll(canvases).toBe(1); + await input.press('ControlOrMeta+z'); + await expect.poll(() => inkAt(page, 150 + 60, 200 + Math.sin(2) * 25)).toBeGreaterThan(0); + // Lasso-selected and deleted: the canvas stays too. + await ribbon.getByRole('button', { name: 'Lasso Select', exact: true }).click(); + await draw(page, box, loop(130, 150, 230, 110)); + await page.keyboard.press('Delete'); + await expect.poll(() => inkAt(page, 150 + 60, 200 + Math.sin(2) * 25)).toBe(0); + await expect.poll(canvases).toBe(1); + expect(errors).toEqual([]); +}); From 03ff9c03edc24fda6be381dc19f0eb0a8bc2d198 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:11:16 -0700 Subject: [PATCH 072/186] Documents: Add no empty Undo step when the point eraser only grazes a stroke Within reach of a stroke's edge but too far to cut it, the point eraser rebuilt the stroke unchanged as a new object, so the drawing was rewritten and an Undo step appeared that did nothing. Such a stroke is now left as it is. --- src/documents/src/engine/ink-geometry.js | 6 ++++-- src/documents/tests/ink-geometry.test.js | 8 ++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/documents/src/engine/ink-geometry.js b/src/documents/src/engine/ink-geometry.js index c988a21dd..76aacb076 100644 --- a/src/documents/src/engine/ink-geometry.js +++ b/src/documents/src/engine/ink-geometry.js @@ -190,12 +190,14 @@ export function eraseFromStroke(stroke, p, radius) { } dense.push({ ...q }); }); - const pieces = []; let run = []; + const pieces = []; let run = [], cut = false; for (const q of dense) { const r = widthAt(stroke.width, q.p, stroke.tool) / 2; - if (dist(q, p) <= radius + r * 0.5) { if (run.length) pieces.push(run); run = []; } + if (dist(q, p) <= radius + r * 0.5) { cut = true; if (run.length) pieces.push(run); run = []; } else run.push(q); } + // Grazing the ink's edge cuts nothing: the stroke stays the same stroke (and no edit is recorded). + if (!cut) return [stroke]; if (run.length) pieces.push(run); return pieces.filter(points => points.length > 1 || points.length === 1 && stroke.points.length === 1) .map(points => ({ ...stroke, points: simplify(points, 0.25) })); diff --git a/src/documents/tests/ink-geometry.test.js b/src/documents/tests/ink-geometry.test.js index 363c959f6..44601874d 100644 --- a/src/documents/tests/ink-geometry.test.js +++ b/src/documents/tests/ink-geometry.test.js @@ -81,6 +81,14 @@ describe('erasing and lasso', () => { expect(eraseFromStroke(stroke, { x: 50, y: 30 }, 5)).toEqual([stroke]); expect(eraseFromStroke({ ...stroke, points: line({ x: 0, y: 0 }, { x: 4, y: 0 }, 4) }, { x: 2, y: 0 }, 10)).toEqual([]); }); + it('leaves a stroke as it is when the point eraser only grazes its edge', () => { + // Touching the ink (within the eraser and the line's half width) but no point within the cut. + const r = widthAt(stroke.width, 0.5, 'pen') / 2, at = { x: 50, y: 5 + r * 0.8 }; + expect(strokeHit(stroke, at, 5)).toBe(true); + const pieces = eraseFromStroke(stroke, at, 5); + expect(pieces).toHaveLength(1); + expect(pieces[0]).toBe(stroke); + }); it('selects strokes mostly inside a loop', () => { const loop = [{ x: -10, y: -10 }, { x: 60, y: -10 }, { x: 60, y: 10 }, { x: -10, y: 10 }]; expect(lassoContains(loop, { points: line({ x: 0, y: 0 }, { x: 50, y: 0 }) })).toBe(true); From 4ada21b574c65a6512f08b17929e09129711d081 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:17:51 -0700 Subject: [PATCH 073/186] Documents: Keep objects stacked as they were when grouping and ungrouping Group wrote its children in text order, but a group's children paint in document order, so an object in front that was anchored earlier in the text ended up behind the other. Ungroup gave the children the group's level plus one per child, tying with or covering objects that were in front of the group. Children are now written back to front, and on Ungroup objects in front of the group move up to stay in front. --- src/documents/src/engine/drawing-groups.js | 9 ++- .../tests/browser/group-stacking.spec.js | 68 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 src/documents/tests/browser/group-stacking.spec.js diff --git a/src/documents/src/engine/drawing-groups.js b/src/documents/src/engine/drawing-groups.js index 2a26be058..29eb122d6 100644 --- a/src/documents/src/engine/drawing-groups.js +++ b/src/documents/src/engine/drawing-groups.js @@ -89,7 +89,9 @@ export async function groupDrawings(editor, images) { let id = nextId(model) + 1000; const ids = { next: () => id++ }; const group = make(doc, WPG, 'wgp', {}, make(doc, WPG, 'cNvGrpSpPr'), make(doc, WPG, 'grpSpPr', {}, make(doc, A, 'xfrm', {}, make(doc, A, 'off', { x: 0, y: 0 }), make(doc, A, 'ext', { cx: int(width), cy: int(height) }), make(doc, A, 'chOff', { x: 0, y: 0 }), make(doc, A, 'chExt', { cx: int(width), cy: int(height) })))); - for (const member of members) { + // A group's children paint in document order: write them back to front, as they were stacked. + const stacked = [...members].sort((a, b) => (a.image.anchor.behind ? 0 : 1) - (b.image.anchor.behind ? 0 : 1) || (a.image.anchor.order || 0) - (b.image.anchor.order || 0) || a.start - b.start); + for (const member of stacked) { const source = member === primary ? { drawing } : materializeDrawing(pkg, model, member.image); if (source.parts) for (const [rel, part] of source.parts) { if (parts.has(rel) && parts.get(rel).path !== part.path) throw new Error('These objects use conflicting pictures and cannot be grouped.'); @@ -216,6 +218,11 @@ export async function ungroupDrawing(editor, image) { if (start === undefined) throw new Error('The group changed before it could be ungrouped.'); model.select(start, start + 1); model.replace('', { checkpoint: false }); for (const [i, fragment] of fragments.entries()) { model.select(start + i, start + i); model.replace(OBJECT, { checkpoint: false, insertedImage: fragment.image }); } + // The children take the group's level and those just above it: objects that were in front of the group move up to stay in front. + const level = image.anchor.order || 1, added = fragments.length - 1, children = new Set(fragments.map(f => f.image.id)); + if (added > 0) for (const p of model.paragraphs) for (const run of p.runs) { + if (run.image?.anchor && !children.has(run.image.id) && (run.image.anchor.order || 0) > level) run.image = { ...run.image, anchor: { ...run.image.anchor, order: run.image.anchor.order + added }, anchorChanged: true }; + } model.select(start, start + 1); return true; }, { revision }); diff --git a/src/documents/tests/browser/group-stacking.spec.js b/src/documents/tests/browser/group-stacking.spec.js new file mode 100644 index 000000000..27b5a3851 --- /dev/null +++ b/src/documents/tests/browser/group-stacking.spec.js @@ -0,0 +1,68 @@ +import { test, expect } from '@playwright/test'; +import { onTab } from './helpers/editor-tools.js'; + +// Group keeps the objects stacked as they were: one in front stays in front inside the group, whichever +// is anchored first in the text; Ungroup puts the objects back in the group's place among the others. +const input = page => page.getByRole('textbox', { name: 'Document text' }); +const drawings = page => page.evaluate(() => { + const m = window.__docs.current.view.engine.model; + let at = 0; const out = []; + for (const p of m.paragraphs) { for (const r of p.runs) { if (r.image?.anchor) out.push({ at, order: r.image.anchor.order, kind: (/prst="(\w+)"/.exec(r.image.shell || '') || [])[1], shapes: [...(r.image.shell || '').matchAll(/ x[1]) }); at += r.text.length; } at++; } + return out; +}); +async function drawShape(page, name, from, to) { + await onTab(page, 'insert'); + await page.locator('#ribbon-panel-insert').getByRole('button', { name: 'Shapes', exact: true }).click(); + await page.getByRole('menuitem', { name, exact: true }).click(); + const box = await page.locator('canvas.canvas-page').first().boundingBox(); + await page.mouse.move(box.x + from[0], box.y + from[1]); await page.mouse.down(); + await page.mouse.move(box.x + to[0], box.y + to[1], { steps: 4 }); await page.mouse.up(); + await expect(page.locator('#ribbon-tab-shapeFormat')).toBeVisible(); +} + +test('grouping and ungrouping keep the objects stacked as they were', async ({ page }) => { + const errors = []; page.on('pageerror', e => errors.push(e.message)); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/mock.html#/new'); + await expect((await onTab(page, 'home')).getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + await input(page).focus(); + for (let i = 0; i < 16; i++) { await page.keyboard.type(`Paragraph ${i + 1} of the text the shapes sit on.`); await page.keyboard.press('Enter'); } + await drawShape(page, 'Rectangle', [300, 420], [460, 520]); + await drawShape(page, 'Oval', [340, 300], [500, 470]); + await drawShape(page, 'Isosceles Triangle', [600, 300], [700, 400]); + // Bring the one of rectangle and oval anchored first in the text forward, over the other (still under the triangle). + let now = await drawings(page); + const [first, second] = ['rect', 'ellipse'].map(kind => now.find(d => d.kind === kind)).sort((a, b) => a.at - b.at); + await page.evaluate(at => { const e = window.__docs.current.view.engine; e.model.select(at, at + 1); e.syncInput(); e.paint(); e.onSelection(); }, first.at); + await onTab(page, 'shapeFormat'); + await page.locator('#ribbon-panel-shapeFormat').getByRole('button', { name: 'Bring Forward', exact: true }).click(); + now = await drawings(page); + const order = kind => now.find(d => d.kind === kind).order; + expect(order(first.kind)).toBeGreaterThan(order(second.kind)); + expect(order('triangle')).toBeGreaterThan(order(first.kind)); + + await onTab(page, 'shapeFormat'); + await page.locator('#ribbon-panel-shapeFormat').getByRole('button', { name: 'Selection Pane', exact: true }).click(); + const pane = page.getByRole('complementary', { name: 'Selection pane' }); + await expect(pane.getByRole('listitem')).toHaveCount(3); + await pane.getByRole('listitem').filter({ hasText: /^Oval/ }).locator('.pic-selection-name').click(); + await pane.getByRole('listitem').filter({ hasText: /^Rectangle/ }).locator('.pic-selection-name').click({ modifiers: ['Shift'] }); + await page.locator('#ribbon-panel-shapeFormat').getByRole('button', { name: 'Group', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Group', exact: true }).click(); + await expect(pane.getByRole('listitem')).toHaveCount(2); + now = await drawings(page); + const group = now.find(d => d.shapes.length === 2); + // Children paint in document order: the one behind first. + expect(group.shapes).toEqual([second.kind, first.kind]); + expect(group.order).toBeLessThan(now.find(d => d.kind === 'triangle').order); + + await page.locator('#ribbon-panel-shapeFormat').getByRole('button', { name: 'Group', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Ungroup' }).click(); + await expect(pane.getByRole('listitem')).toHaveCount(3); + now = await drawings(page); + const orders = Object.fromEntries(now.map(d => [d.kind, d.order])); + // Back as they were, both behind the triangle; no two share a level. + expect(orders[second.kind]).toBeLessThan(orders[first.kind]); + expect(orders[first.kind]).toBeLessThan(orders.triangle); + expect(errors).toEqual([]); +}); From c38c85fd49a6a8fc603a86899c1e5332ee10c430 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:20:56 -0700 Subject: [PATCH 074/186] Documents: Make Hide All, Show All and Compress Pictures one Undo step The Selection pane's Hide All and Show All, and Compress Pictures on every picture, rewrote one drawing at a time, each its own undo step, and a failure part way left some changed. They now rewrite all the drawings in one transaction. --- src/documents/src/engine/drawing-format.js | 44 +++++++++++++++++++ src/documents/src/views/pictures.js | 30 ++++++++++--- src/documents/src/views/selection-pane.js | 6 +-- .../tests/browser/group-stacking.spec.js | 27 ++++++++++++ 4 files changed, 96 insertions(+), 11 deletions(-) diff --git a/src/documents/src/engine/drawing-format.js b/src/documents/src/engine/drawing-format.js index 2cc4a6677..72979c1df 100644 --- a/src/documents/src/engine/drawing-format.js +++ b/src/documents/src/engine/drawing-format.js @@ -172,6 +172,50 @@ export async function formatDrawing(editor, image, edit, { label = 'drawing' } = return placeDrawing(editor, fragment, { replace: image, revision }); } +/** + * Rewrite several drawings with the same edit as one undo step (Hide All, Compress Pictures on every + * picture). Drawings the edit leaves unchanged are skipped. Returns how many changed; throws, changing + * none, when one cannot be rewritten. + */ +export async function formatDrawings(editor, targets, edit, { label = 'drawing' } = {}) { + if (editor.readOnly) throw new Error('This document is read-only.'); + const pkg = editor.shapePackage?.(); + if (!pkg) throw new Error('Formatting is not available for this document.'); + editor.finishComposition?.(); + const model = editor.model, revision = editor.session.revision, fragments = []; + for (const image of targets) { + if (!images(model).includes(image)) throw new Error(`Select the ${label} again.`); + const { drawing, parts } = materializeDrawing(pkg, model, image), added = []; + const context = { + parts, + addPart(bytes, contentType, stem = 'canvas-edit') { + const extension = Object.entries(CONTENT_TYPES).find(([, type]) => type === contentType)?.[0] || 'bin'; + const used = new Set([...fragments.flatMap(f => f.added.map(a => a.path))]); + let part; do part = freshPart(model, pkg.parts, 'media', stem, extension); while (used.has(part.path) || [...fragments.flatMap(f => f.added)].some(a => a.relId === part.relId)); + added.push({ ...part, bytes, contentType, relType: REL_TYPES.image }); + return part.relId; + }, + partBytes(relId) { return parts.get(relId)?.bytes || added.find(part => part.relId === relId)?.bytes || null; }, + release(relId) { const part = parts.get(relId); if (part?.existing && part.path) (pkg.releasedMedia ||= new Set()).add(part.path); }, + }; + const before = new XMLSerializer().serializeToString(drawing); + const result = await edit(drawing, context); + if (result === false || new XMLSerializer().serializeToString(drawing) === before) continue; + reserveRotatedBounds(drawing, image); + fragments.push({ image, added, fragment: await readFormattedDrawing(pkg, model, image, drawing, parts, added) }); + } + if (!fragments.length) return 0; + const ok = await applyDrawingFragments(editor, fragments.map(f => f.fragment), model => { + const replaced = new Map(fragments.map(({ image, fragment }) => [image, { ...fragment.image, id: image.id }])); + if ([...replaced.keys()].some(image => !images(model).includes(image))) throw new Error('The document changed while the drawings were prepared. Try again.'); + model.checkpoint(); + for (const p of model.paragraphs) for (const run of p.runs) if (replaced.has(run.image)) run.image = replaced.get(run.image); + model.didChange(); + return true; + }, { revision }); + return ok ? fragments.length : 0; +} + /** * A picture's frame reserves room for its rotated bounds (wp:effectExtent), which the reader takes as saved. * An edit that turns or resizes the picture through its XML (the rotation handle, Rotate, Reset Picture, diff --git a/src/documents/src/views/pictures.js b/src/documents/src/views/pictures.js index a7599af2d..4bfe80b2a 100644 --- a/src/documents/src/views/pictures.js +++ b/src/documents/src/views/pictures.js @@ -13,7 +13,7 @@ import { toast } from '../ui/toast.js'; import { engineApi } from '../lib/engine-api.js'; import { drawShapeThumbnail } from '../ui/shape-controls.js'; import { galleryItems } from './insert-gallery.js'; -import { formatDrawing, drawingKind, drawingParts, currentDrawingXml } from '../engine/drawing-format.js'; +import { formatDrawing, formatDrawings, drawingKind, drawingParts, currentDrawingXml } from '../engine/drawing-format.js'; import { describeDrawing, setFill, setOutline, setEffects, set3d, setGeometry, setTransform, setBlipEffects, setCrop, setBodyProperties, setDocProperties, writeFrame, readFrame, themeColors, child, childrenOf, A, WP, R } from '../engine/drawing-xml.js'; import { frameOf, offsetsFor, moveDrawings, alignmentMoves, POSITION_PRESETS, presetPosition } from '../engine/drawing-arrange.js'; @@ -101,6 +101,22 @@ export function createPictures({ editor, pkg, error, onChange = () => {}, base = queue = result.catch(() => {}); return result; } + /** The same edit on several drawings, as one undo step; resolves to how many changed (0 when it failed). */ + function applyAll(label, targets, edit) { + const ids = targets.map(image => image.id); + const run = async () => { + const engine = ready(); if (!engine || !ids.length) return 0; + const all = runImages(engine.model), current = ids.map(id => all.find(i => i.id === id)).filter(Boolean); + try { + const count = await formatDrawings(engine, current, edit, { label }); + if (count) { onChange(); refreshPanes(); } + return count; + } catch (e) { fail(friendly(e, label)); return 0; } + }; + const result = queue.then(run, run); + queue = result.catch(() => {}); + return result; + } /** An edit of the drawing's shape properties (spPr / grpSpPr). */ const props = fn => async (drawing, context) => { const parts = drawingParts(drawing); @@ -147,6 +163,8 @@ export function createPictures({ editor, pkg, error, onChange = () => {}, base = altText: ({ description, title, decorative }, image) => apply('Alt Text', async drawing => { const { docPr } = drawingParts(drawing); setDocProperties(docPr, { description, title, decorative }); }, image ?? selected()), rename: (image, name) => apply('Rename', async drawing => { setDocProperties(drawingParts(drawing).docPr, { name }); }, image), setHidden: (image, hidden) => apply(hidden ? 'Hide' : 'Show', async drawing => { const { docPr } = drawingParts(drawing); if (hidden) docPr.setAttribute('hidden', '1'); else docPr.removeAttribute('hidden'); }, image), + /** Hide All / Show All: every listed drawing in one step. */ + setHiddenAll: (images, hidden) => applyAll(hidden ? 'Hide All' : 'Show All', images.filter(image => !!image.hidden !== hidden), async drawing => { const { docPr } = drawingParts(drawing); if (hidden) docPr.setAttribute('hidden', '1'); else docPr.removeAttribute('hidden'); }), rotate: (degrees, label = 'Rotate') => rotateBy(degrees, label), flip: axis => flip(axis), resetPicture: (size = false) => apply(size ? 'Reset Picture & Size' : 'Reset Picture', async drawing => { @@ -352,13 +370,11 @@ export function createPictures({ editor, pkg, error, onChange = () => {}, base = openCompressDialog({ selectedOnly: isPicture(selected()), onApply: async ({ ppi, deleteCropped, selectedOnly }) => { const engine = ready(); if (!engine) return; const targets = selectedOnly ? [selected()] : runImages(engine.model).filter(isPicture); - let count = 0; - for (const image of targets) { - const current = runImages(engine.model).find(i => i.id === image.id) || image; - try { if (await formatDrawing(engine, current, compressEdit({ ppi, deleteCropped }), { label: 'Compress' })) count++; } catch (e) { fail(friendly(e, 'Compress Pictures')); break; } - } + // Every picture in one step: one Undo restores them all, and a failure leaves none compressed. + let count; + try { count = await formatDrawings(engine, targets, compressEdit({ ppi, deleteCropped }), { label: 'Compress' }); } catch (e) { fail(friendly(e, 'Compress Pictures')); return; } toast(count ? `Compressed ${count} ${count === 1 ? 'picture' : 'pictures'}.` : 'The pictures are already at that resolution.'); - onChange(); + onChange(); refreshPanes(); } }); } const pictureInput = el('input', { type: 'file', accept: 'image/png,image/jpeg,image/gif,image/webp,image/bmp,image/svg+xml', hidden: true, 'aria-label': 'Change picture file', onchange: async () => { diff --git a/src/documents/src/views/selection-pane.js b/src/documents/src/views/selection-pane.js index 0dc979f54..a2113db1d 100644 --- a/src/documents/src/views/selection-pane.js +++ b/src/documents/src/views/selection-pane.js @@ -74,10 +74,8 @@ export function createPane(context) { label.replaceWith(input); input.focus(); input.select(); } async function setAll(hidden) { - for (const image of ordered()) { - const current = runImages(editor()?.model).find(i => i.id === image.id); - if (current && !!current.hidden !== hidden && !(await commands.setHidden(current, hidden))) break; - } + // One step for all of them: one Undo shows or hides them again. + await commands.setHiddenAll(ordered(), hidden); refresh(true); } function restack(images) { diff --git a/src/documents/tests/browser/group-stacking.spec.js b/src/documents/tests/browser/group-stacking.spec.js index 27b5a3851..20b7cd385 100644 --- a/src/documents/tests/browser/group-stacking.spec.js +++ b/src/documents/tests/browser/group-stacking.spec.js @@ -66,3 +66,30 @@ test('grouping and ungrouping keep the objects stacked as they were', async ({ p expect(orders[first.kind]).toBeLessThan(orders.triangle); expect(errors).toEqual([]); }); + +test('Hide All and Show All in the Selection pane are one Undo each', async ({ page }) => { + const errors = []; page.on('pageerror', e => errors.push(e.message)); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/mock.html#/new'); + await expect((await onTab(page, 'home')).getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + await input(page).focus(); + for (let i = 0; i < 8; i++) { await page.keyboard.type(`Paragraph ${i + 1} of the text the shapes sit on.`); await page.keyboard.press('Enter'); } + await drawShape(page, 'Rectangle', [300, 200], [400, 260]); + await drawShape(page, 'Oval', [450, 200], [550, 260]); + await drawShape(page, 'Isosceles Triangle', [600, 200], [700, 260]); + const hidden = () => page.evaluate(() => window.__docs.current.view.engine.model.paragraphs.flatMap(p => p.runs).filter(r => r.image).map(r => !!r.image.hidden)); + await onTab(page, 'shapeFormat'); + await page.locator('#ribbon-panel-shapeFormat').getByRole('button', { name: 'Selection Pane', exact: true }).click(); + const pane = page.getByRole('complementary', { name: 'Selection pane' }); + await pane.getByRole('button', { name: 'Hide All', exact: true }).click(); + await expect.poll(hidden).toEqual([true, true, true]); + await page.getByRole('button', { name: 'Undo', exact: true }).first().click(); + await expect.poll(hidden).toEqual([false, false, false]); + await page.getByRole('button', { name: 'Redo', exact: true }).first().click(); + await expect.poll(hidden).toEqual([true, true, true]); + await pane.getByRole('button', { name: 'Show All', exact: true }).click(); + await expect.poll(hidden).toEqual([false, false, false]); + await page.getByRole('button', { name: 'Undo', exact: true }).first().click(); + await expect.poll(hidden).toEqual([true, true, true]); + expect(errors).toEqual([]); +}); From a7232511804284e0df9342e38439840eca79fc9b Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:23:05 -0700 Subject: [PATCH 075/186] Documents: Make Set Transparent Color take the color under the pointer in cropped, flipped pictures The point was mapped through the crop first and mirrored after, so in a flipped picture cropped unevenly the color came from elsewhere, sometimes from a part cropped away; a click on the far edge read past the picture. The point is now mirrored within what shows, then mapped through the crop, and kept inside the picture. --- src/documents/src/views/pictures.js | 9 +++--- .../browser/transparent-color-pick.spec.js | 32 +++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 src/documents/tests/browser/transparent-color-pick.spec.js diff --git a/src/documents/src/views/pictures.js b/src/documents/src/views/pictures.js index 4bfe80b2a..da9a084e2 100644 --- a/src/documents/src/views/pictures.js +++ b/src/documents/src/views/pictures.js @@ -410,11 +410,12 @@ export function createPictures({ editor, pkg, error, onChange = () => {}, base = const point = pictureFraction(frame.getBoundingClientRect(), image, e.clientX, e.clientY); if (!point) { done(); return; } e.preventDefault(); e.stopPropagation(); - const crop = image.crop || {}; - const { u, v } = point; + // The flip mirrors the cropped picture: mirror the point within what shows, then map it through the crop. + const shown = engine.cropInfo?.(image) || { crop: image.crop || {}, flipH: !!image.flipH, flipV: !!image.flipV }, crop = shown.crop || {}; + const u = shown.flipH ? 1 - point.u : point.u, v = shown.flipV ? 1 - point.v : point.v; const sx = ((crop.left || 0) + u * (100000 - (crop.left || 0) - (crop.right || 0))) / 100000, sy = ((crop.top || 0) + v * (100000 - (crop.top || 0) - (crop.bottom || 0))) / 100000; - const probe = new OffscreenCanvas(1, 1).getContext('2d'); - probe.drawImage(source, Math.floor((image.flipH ? 1 - sx : sx) * source.width), Math.floor((image.flipV ? 1 - sy : sy) * source.height), 1, 1, 0, 0, 1, 1); + const probe = new OffscreenCanvas(1, 1).getContext('2d'), pixel = (fraction, size) => Math.max(0, Math.min(size - 1, Math.floor(fraction * size))); + probe.drawImage(source, pixel(sx, source.width), pixel(sy, source.height), 1, 1, 0, 0, 1, 1); const [r, g, b] = probe.getImageData(0, 0, 1, 1).data; const rgb = [r, g, b].map(c => c.toString(16).padStart(2, '0')).join('').toUpperCase(); done(); diff --git a/src/documents/tests/browser/transparent-color-pick.spec.js b/src/documents/tests/browser/transparent-color-pick.spec.js new file mode 100644 index 000000000..8b952f4a9 --- /dev/null +++ b/src/documents/tests/browser/transparent-color-pick.spec.js @@ -0,0 +1,32 @@ +import { test, expect } from '@playwright/test'; +import { onTab } from './helpers/editor-tools.js'; + +// Set Transparent Color takes the color under the pointer, also in a cropped, flipped picture: the crop +// is of the picture as stored, and the flip mirrors what is left of it. +// A 40 × 20 picture: red on its left half, blue on its right half. +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAACgAAAAUCAIAAABwJOjsAAAAJ0lEQVR4nGO4IydHNqJA6x2GUYtHLR61eNTiUYtHLR61eNTikWMxALnMay7BH6g1AAAAAElFTkSuQmCC', 'base64'); + +test('Set Transparent Color picks the color shown under the pointer in a cropped, flipped picture', async ({ page }) => { + const errors = []; page.on('pageerror', e => errors.push(e.message)); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/mock.html#/new'); + await expect((await onTab(page, 'home')).getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + await page.getByRole('textbox', { name: 'Document text' }).focus(); + await onTab(page, 'insert'); + await page.getByLabel('Insert picture file').setInputFiles({ name: 'halves.png', mimeType: 'image/png', buffer: PNG }); + const panel = page.locator('#ribbon-panel-picture'); + await expect(panel).toBeVisible(); + // Keep the right 40 % of the picture (all blue), mirrored, and make it large enough to click in. + await page.evaluate(() => { const e = window.__docs.current.view.engine; e.execute(() => e.model.setImageProperties({ width: 914400 * 2, height: 914400, crop: { left: 60000 }, flipH: true })); }); + await expect.poll(() => page.evaluate(() => window.__docs.current.view.engine.model.selectedImage?.flipH)).toBe(true); + await panel.getByRole('button', { name: 'Color', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Set Transparent Color' }).click(); + const frame = await page.locator('.canvas-image-frame').boundingBox(); + // Near the left edge of what shows: blue, from the right of the stored picture. + await page.mouse.click(frame.x + frame.width * 0.1, frame.y + frame.height / 2); + await expect.poll(() => page.evaluate(() => { + const m = window.__docs.current.view.engine.model, image = m.paragraphs.flatMap(p => p.runs).find(r => r.image).image; + return / Date: Tue, 27 Oct 2026 22:25:24 -0700 Subject: [PATCH 076/186] Documents: Let the keyboard leave the Diagram dialog's text box Tab and Shift+Tab always indented or outdented the line in the diagram's text, so keyboard users could not reach Colors or Insert; only Escape, which cancels, left the box. They now indent and outdent at the start of a line, before its text, and move focus elsewhere. --- src/documents/src/views/insert-dialogs.js | 16 +++++++++------ .../browser/chart-diagram-keyboard.spec.js | 20 ++++++++++++++++++- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/documents/src/views/insert-dialogs.js b/src/documents/src/views/insert-dialogs.js index ed75d0b7a..bc2748ee9 100644 --- a/src/documents/src/views/insert-dialogs.js +++ b/src/documents/src/views/insert-dialogs.js @@ -348,16 +348,20 @@ export function openDiagramDialog({ initial = null, editing = false, onInsert, o }); const render = () => preview.replaceChildren(diagramSketch(layout, parseOutline(outline.value), colors.value)); outline.addEventListener('input', render); colors.addEventListener('change', render); - outline.addEventListener('keydown', e => { if (e.key === 'Tab' && !isComposing(e)) { - // Tab and Shift+Tab indent and outdent the current line, as in an outline. - e.preventDefault(); const start = outline.value.lastIndexOf('\n', outline.selectionStart - 1) + 1, caret = outline.selectionStart; - if (e.shiftKey) { if (outline.value.startsWith(' ', start)) { outline.setRangeText('', start, start + 2); outline.selectionStart = outline.selectionEnd = Math.max(start, caret - 2); } } + outline.addEventListener('keydown', e => { + if (e.key !== 'Tab' || isComposing(e) || e.ctrlKey || e.metaKey || e.altKey) return; + // At the start of a line (before its text), Tab and Shift+Tab indent and outdent it, as in an outline. + // Elsewhere they move on to the next or previous control, so the keyboard can leave the box. + const caret = outline.selectionStart, start = outline.value.lastIndexOf('\n', caret - 1) + 1, lead = /^ */.exec(outline.value.slice(start))[0].length; + if (outline.selectionEnd !== caret || caret - start > lead || e.shiftKey && lead < 2) return; + e.preventDefault(); + if (e.shiftKey) { outline.setRangeText('', start, start + 2); outline.selectionStart = outline.selectionEnd = Math.max(start, caret - 2); } else { outline.setRangeText(' ', start, start); outline.selectionStart = outline.selectionEnd = caret + 2; } render(); - } }); + }); const dialog = formDialog({ title: editing ? 'Edit Diagram' : 'Diagram', submit: editing ? 'Update' : 'Insert', wide: true, className: 'insert-diagram', onClose, content: [ el('div.insert-diagram-grid', {}, layouts, - el('div.insert-diagram-editor', {}, field('Text (one item per line; indent with Tab for details)', outline), field('Colors', colors), preview))], + el('div.insert-diagram-editor', {}, field('Text (one item per line; Tab at the start of a line indents it for details)', outline), field('Colors', colors), preview))], onSubmit: () => { const items = parseOutline(outline.value); if (!items.length) throw new Error('Type at least one item.'); diff --git a/src/documents/tests/browser/chart-diagram-keyboard.spec.js b/src/documents/tests/browser/chart-diagram-keyboard.spec.js index 737002f42..6b6abde62 100644 --- a/src/documents/tests/browser/chart-diagram-keyboard.spec.js +++ b/src/documents/tests/browser/chart-diagram-keyboard.spec.js @@ -38,6 +38,24 @@ test('chart types and diagram layouts are chosen with the keyboard', async ({ pa await page.keyboard.press('End'); await expect(layouts.getByRole('option', { name: /^Basic Pyramid/ })).toHaveAttribute('aria-selected', 'true'); await page.keyboard.press('Tab'); - await expect(diagram.getByRole('textbox', { name: 'Diagram text' })).toBeFocused(); + const text = diagram.getByRole('textbox', { name: 'Diagram text' }); + await expect(text).toBeFocused(); + // Tab at the start of a line indents it; Shift+Tab there outdents it. + await text.fill('Plan\nBuild'); + const caret = at => text.evaluate((el, at) => el.setSelectionRange(at, at), at); + await caret(5); + await text.press('Tab'); + await expect(text).toHaveValue('Plan\n Build'); + await expect(text).toBeFocused(); + await text.press('Shift+Tab'); + await expect(text).toHaveValue('Plan\nBuild'); + // After the line's text, Tab moves on, so the keyboard can reach the rest of the dialog. + await caret(10); + await text.press('Tab'); + await expect(text).not.toBeFocused(); + await expect(text).toHaveValue('Plan\nBuild'); + await text.focus(); await caret(0); + await text.press('Shift+Tab'); + await expect(text).not.toBeFocused(); expect(errors).toEqual([]); }); From bb3bb14f6e05f1ff521fd24a244c625ffe759c5d Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:25:55 -0700 Subject: [PATCH 077/186] Documents: Stop Edit Equation from bracketing a quoted word under a script "x"^2 opened in Edit Equation as ("x")^2, and Update added visible parentheses. A quoted plain-text run now counts as one operand, as a letter does. --- src/documents/src/engine/math-linear.js | 3 ++- src/documents/tests/math-linear.test.js | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/documents/src/engine/math-linear.js b/src/documents/src/engine/math-linear.js index 9fe8b7a28..dca7e40a9 100644 --- a/src/documents/src/engine/math-linear.js +++ b/src/documents/src/engine/math-linear.js @@ -475,7 +475,8 @@ function fractionPart(node) { // The base of a script or mark: one operand the script attaches to. function base(node) { const items = editableItems(node); - if (items.length === 1 && (items[0].kind === 'run' && singleChar(items[0].text) || ['d', 'acc', 'bar'].includes(items[0].kind) && !/^\w+\(/.test(items[0].text))) return items[0].text; + // A quoted plain-text run ("x") is one operand too. + if (items.length === 1 && (items[0].kind === 'run' && singleChar(items[0].text) || items[0].kind === 'plain' || ['d', 'acc', 'bar'].includes(items[0].kind) && !/^\w+\(/.test(items[0].text))) return items[0].text; return bracket(editableSequence(node)); } // Structures nested more deeply than Office Math's depth limit (each spans two or more elements) diff --git a/src/documents/tests/math-linear.test.js b/src/documents/tests/math-linear.test.js index 0e4caaf39..3f5f2ab63 100644 --- a/src/documents/tests/math-linear.test.js +++ b/src/documents/tests/math-linear.test.js @@ -210,6 +210,11 @@ describe('editing an existing equation', () => { same('sin x^2'); same('sin (a+b)'); same('sin^2 x'); }); + it('writes a quoted word with a script as it was typed', () => { + expect(same('"x"^2')).toBe('"x"^2'); + expect(same('"speed"_i')).toBe('"speed"_i'); + }); + it('writes every structure in a form that builds it again', () => { for (const { linear } of EQUATION_GALLERY) same(linear); // Binomials, matrices and equation arrays stay themselves; roots keep their degree. From 85d917b7381ac0220161fad64d140117a7b68597 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:21:23 -0700 Subject: [PATCH 078/186] Documents: Find text with curly quotes by typing straight ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autocorrect turns typed quotes into typographic ones, but the Find box and the Navigation pane search take quotes as typed, so searching for don't never found the don’t typed in the document. A straight quote in the query now also finds the curly forms (in the basic and advanced searches; wildcards still match exactly). --- src/documents/src/engine/advanced-search.js | 5 ++- src/documents/src/engine/search.js | 17 ++++++-- .../tests/find-typographic-quotes.test.js | 41 +++++++++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 src/documents/tests/find-typographic-quotes.test.js diff --git a/src/documents/src/engine/advanced-search.js b/src/documents/src/engine/advanced-search.js index 8516d4bdb..ae687bb59 100644 --- a/src/documents/src/engine/advanced-search.js +++ b/src/documents/src/engine/advanced-search.js @@ -93,7 +93,10 @@ function literalSource(query, { ignorePunctuation, ignoreWhitespace }) { const gap = filler.length ? `[${filler.join('')}]*` : ''; const parts = tokens(query).filter(t => t.pattern || !(ignorePunctuation && /^\p{P}$/u.test(t.literal)) && !(ignoreWhitespace && /^\s$/u.test(t.literal))); if (!parts.length) throw new Error('Enter text to find. Punctuation and spaces alone are ignored with these options.'); - return parts.map(t => t.pattern ?? escapeRegExp(t.literal)).join(gap); + // A straight quote also finds the typographic ones, as the basic search does (search.js); wildcards match exactly. + const families = ["'‘’", '"“”'].filter(forms => parts.some(t => t.literal === forms[0])); + const quote = literal => { const forms = families.find(forms => [...forms].includes(literal)); return forms && `[${forms}]`; }; + return parts.map(t => t.pattern ?? quote(t.literal) ?? escapeRegExp(t.literal)).join(gap); } /** A compiled matcher for these options. Throws a readable error for an invalid pattern. */ diff --git a/src/documents/src/engine/search.js b/src/documents/src/engine/search.js index 0cbffdc9a..660677334 100644 --- a/src/documents/src/engine/search.js +++ b/src/documents/src/engine/search.js @@ -77,21 +77,32 @@ function simpleMatcher(query, matchCase) { }; } +// A straight quote in the query also finds the typographic quotes autocorrect makes of it as you type, as Find +// does in desktop word processors: "don't" finds "don’t". Each family folds to its straight form (one code unit +// for one, so offsets stay); a curly quote typed in the query then finds either form too. +const QUOTE_FAMILIES = [[/'/, /[‘’]/g, "'"], [/"/, /[“”]/g, '"']]; +function quoteFolding(query) { + const families = QUOTE_FAMILIES.filter(([straight]) => straight.test(query)); + return families.length ? text => families.reduce((folded, [, curly, straight]) => folded.replace(curly, straight), text) : null; +} + export function findText(paragraphs, query, { matchCase = false, wholeWord = false, skipDeleted = false } = {}) { checkedSearchText(query); if (!query) return []; + const fold = quoteFolding(query); + if (fold) query = fold(query); const matchLiteral = literalMatcher(query, matchCase), simpleQuery = SIMPLE.test(query), matchSimple = simpleQuery && simpleMatcher(query, matchCase); const matches = []; let at = 0; paragraphs.forEach((p, paragraph) => { - const text = p.runs.map(r => r.text).join(''); + const text = p.runs.map(r => r.text).join(''), searched = fold ? fold(text) : text; const deleted = skipDeleted ? deletedSpans(p) : null; // Segmenting every paragraph of a long document on each search held up typing while Find was open. - const simple = simpleQuery && SIMPLE.test(text); + const simple = simpleQuery && SIMPLE.test(searched); const boundaries = simple ? null : new Set([...inlineSegments(text)].map(s => s.index).concat(text.length)); const wordStarts = new Set(), wordEnds = new Set(); if (wholeWord) for (const segment of words.segment(text)) if (segment.isWordLike) { wordStarts.add(segment.index); wordEnds.add(segment.index + segment.segment.length); } let previousEnd = 0; - for (const { start, end } of simple ? matchSimple(text) : matchLiteral(text)) { + for (const { start, end } of simple ? matchSimple(searched) : matchLiteral(searched)) { if (start >= previousEnd && (simple || boundaries.has(start) && boundaries.has(end)) && (!wholeWord || wordStarts.has(start) && wordEnds.has(end)) && !overlaps(deleted, start, end)) { matches.push({ start: at + start, end: at + end, paragraph, offset: start, text: text.slice(start, end) }); previousEnd = end; } diff --git a/src/documents/tests/find-typographic-quotes.test.js b/src/documents/tests/find-typographic-quotes.test.js new file mode 100644 index 000000000..d86bb4006 --- /dev/null +++ b/src/documents/tests/find-typographic-quotes.test.js @@ -0,0 +1,41 @@ +import { beforeAll, afterAll, it, expect } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { TextDocument, paragraph } from '../src/engine/model.js'; +import { findText } from '../src/engine/search.js'; +import { advancedFind } from '../src/engine/advanced-search.js'; + +let dom; +beforeAll(() => { dom = new JSDOM(''); globalThis.DOMParser = dom.window.DOMParser; }); +afterAll(() => { dom.window.close(); delete globalThis.DOMParser; }); + +// Autocorrect turns typed quotes into typographic ones, while the Find box takes them as typed. +const typed = 'I don’t know. It’s “fine”, isn\'t it? ‘Quoted’ and "plain".'; +const find = (query, options) => findText([paragraph(typed)], query, options).map(m => m.text); +const advanced = (query, options = {}) => advancedFind([paragraph(typed)], query, { advanced: true, ...options }, {}).map(m => m.text); + +it('finds typographic quotes with a straight quote typed in Find', () => { + expect(find("don't")).toEqual(['don’t']); + expect(find("It's", { matchCase: true, wholeWord: true })).toEqual(['It’s']); + expect(find('"fine"')).toEqual(['“fine”']); + expect(find("'")).toEqual(['’', '’', "'", '‘', '’']); + expect(find('"')).toEqual(['“', '”', '"', '"']); + // A query with curly quotes only still finds exactly those. + expect(find('don’t')).toEqual(['don’t']); + expect(find('’')).toEqual(['’', '’', '’']); + // The matches are where the document's own text is. + const [match] = findText([paragraph(typed)], "isn't"); + expect(typed.slice(match.start, match.end)).toBe("isn't"); +}); + +it('finds them with the advanced options too, but not with wildcards', () => { + expect(advanced("don't", { matchPrefix: true })).toEqual(['don’t']); + expect(advanced('"fine"^?')).toEqual(['“fine”,']); + expect(advanced("don't", { wildcards: true })).toEqual([]); + expect(advanced('[[]')).toEqual([]); +}); + +it('replaces typographic quotes found by a straight one', () => { + const doc = new TextDocument([paragraph(typed)]); + expect(doc.replaceMatches("don't", 'do not', {}, true)).toBe(1); + expect(doc.text).toBe(typed.replace('don’t', 'do not')); +}); From ed694017c858e6ecdf02605830e5ffe226d245fd Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:30:28 -0700 Subject: [PATCH 079/186] Documents: Keep a word ignored once ignored after editing the text before it Ignore Once remembered the word by where it started, so typing or deleting anything earlier in the document moved the word away from that place: it was marked again, and the Spelling pane stopped at it once more. The ignored places now follow each edit. --- src/documents/src/views/proofing.js | 29 ++++++++++++++++++-- src/documents/tests/browser/spelling.spec.js | 28 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/documents/src/views/proofing.js b/src/documents/src/views/proofing.js index bf07fac16..33eca27f6 100644 --- a/src/documents/src/views/proofing.js +++ b/src/documents/src/views/proofing.js @@ -16,6 +16,29 @@ export const spellingEnabled = () => { try { return localStorage.getItem(ENABLED export function createProofing({ editor, pkg, error, onState = () => {} }) { let enabled = spellingEnabled(); const ignored = new Set(), ignoredOnce = new Set(); + // Ignore Once marks one occurrence by where it starts in the body text `ignoredText`. Text typed or deleted + // before it moves it: the marks follow the edit, so the occurrence stays ignored (and no other takes its place). + let ignoredText = null; + function followIgnored(text) { + const old = ignoredText; + ignoredText = text; + if (old === null || old === text || !ignoredOnce.size) return; + const shortest = Math.min(old.length, text.length); + let prefix = 0, suffix = 0; + while (prefix < shortest && old.charCodeAt(prefix) === text.charCodeAt(prefix)) prefix++; + while (suffix < shortest - prefix && old.charCodeAt(old.length - 1 - suffix) === text.charCodeAt(text.length - 1 - suffix)) suffix++; + const moved = []; + for (const key of ignoredOnce) { + const colon = key.indexOf(':'), start = Number(key.slice(0, colon)), word = key.slice(colon + 1); + // An edit inside the word ends the choice: it is another word now. + if (start + word.length <= prefix) moved.push(key); + else if (start >= old.length - suffix) moved.push(`${start + text.length - old.length}:${word}`); + } + ignoredOnce.clear(); + for (const key of moved) ignoredOnce.add(key); + } + /** Ignore misspelling `m` of the latest check here only. */ + function ignoreHere(m) { followIgnored(results.text); ignoredOnce.add(`${m.start}:${m.word}`); } const known = new Map(); let results = { revision: -1, text: '', misspelled: [], overflow: false }; let timer = null, speller = null, failure = null, typedAt = -1; @@ -52,6 +75,7 @@ export function createProofing({ editor, pkg, error, onState = () => {} }) { const caret = engine.session.path === null && engine.model.range[0] === engine.model.range[1] ? engine.model.range[0] : -1; const misspelled = []; let overflow = false, skipped = null; + followIgnored(text); const words = saved && !saved.ranges.length ? [] : wordsToCheck(proofingText(text, body.paragraphs)); for (const word of saved ? wordsInSavedRanges(words, saved.ranges) : words) { if (ignored.has(word.word) || ignoredOnce.has(`${word.start}:${word.word}`)) continue; @@ -131,6 +155,7 @@ export function createProofing({ editor, pkg, error, onState = () => {} }) { const engine = engineReady(); if (!engine || !editable()) return false; engine.finishComposition(); if (engine.session.path !== null) engine.editStory(null); + followIgnored(engine.session.body.text); const targets = occurrences(word).filter(m => !ignoredOnce.has(`${m.start}:${m.word}`)).reverse(); if (!targets.length) return false; const ok = engine.execute(() => { for (const m of targets) { engine.model.select(m.wordStart ?? m.start, m.wordEnd ?? m.end); engine.model.replace(text); } }); @@ -161,7 +186,7 @@ export function createProofing({ editor, pkg, error, onState = () => {} }) { replaceAll(current.word, choice); queueMicrotask(() => next(at ? at.start : current.start)); }); - const ignoreOnce = button('Ignore Once', () => { if (!current) return; ignoredOnce.add(`${current.start}:${current.word}`); next(current.end); }); + const ignoreOnce = button('Ignore Once', () => { if (!current) return; ignoreHere(current); next(current.end); }); const ignoreAllButton = button('Ignore All', () => { if (!current) return; ignoreAll(current.word); next(current.end); }); const add = button('Add to Dictionary', () => { if (!current) return; addToDictionary(current.word); next(current.end); }); const actions = el('div.ed-proof-actions', {}, change, changeAll, ignoreOnce, ignoreAllButton, add); @@ -262,7 +287,7 @@ export function createProofing({ editor, pkg, error, onState = () => {} }) { suggest(word) { return speller?.suggest(word) || []; }, change(m, text) { return replaceWord(m, text); }, changeAll(word, text) { return replaceAll(word, text); }, - ignoreOnce(m) { ignoredOnce.add(`${m.start}:${m.word}`); schedule(0); }, + ignoreOnce(m) { ignoreHere(m); schedule(0); }, ignoreAll, addToDictionary, /** Forget Ignore and Ignore All choices for this session (Reset Ignored Words). */ resetIgnored() { ignored.clear(); ignoredOnce.clear(); known.clear(); rechecked = true; schedule(0); onState(); }, diff --git a/src/documents/tests/browser/spelling.spec.js b/src/documents/tests/browser/spelling.spec.js index 6d8b1c038..3094e092f 100644 --- a/src/documents/tests/browser/spelling.spec.js +++ b/src/documents/tests/browser/spelling.spec.js @@ -136,3 +136,31 @@ test('a picture in front of the text covers the spelling marks under it, as it c await expect.poll(async () => (await marks()).beside).toBeGreaterThan(0); expect((await marks()).covered).toBe(0); }); + +test('a word ignored once stays ignored after text is typed or deleted before it', async ({ page }) => { + const input = await newDocument(page, 'Zorblax met the Zorblax there.'); + await expect(proofing(page)).toHaveAttribute('aria-label', /2 errors found/); + await input.press('ControlOrMeta+Home'); + await input.press('F7'); + const pane = page.getByRole('complementary', { name: 'Spelling' }); + await expect(pane.locator('mark')).toHaveText('Zorblax'); + await pane.getByRole('button', { name: 'Ignore Once', exact: true }).click(); + await expect(proofing(page)).toHaveAttribute('aria-label', /1 error found/); + // The pane goes on from the start: the first is still the one ignored, the other is the one marked. + const reviewFromStart = async at => { + await input.press('ControlOrMeta+Home'); + await input.press('F7'); + await expect(pane.locator('mark')).toHaveText('Zorblax'); + await expect.poll(() => input.evaluate(el => el.selectionStart)).toBe(at); + await pane.getByRole('button', { name: 'Close Spelling pane' }).click(); + }; + await pane.getByRole('button', { name: 'Close Spelling pane' }).click(); + await input.press('ControlOrMeta+Home'); + await page.keyboard.type('Hi '); + await expect(input).toHaveValue('Hi Zorblax met the Zorblax there.'); + await reviewFromStart(19); + await input.press('ControlOrMeta+Home'); + for (let i = 0; i < 2; i++) await input.press('Delete'); + await expect(input).toHaveValue(' Zorblax met the Zorblax there.'); + await reviewFromStart(17); +}); From 5bb227bab79f31e9cce1dc097d470653dea7491f Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:36:26 -0700 Subject: [PATCH 080/186] Documents: Offer the contraction for a word missing its apostrophe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dictionary's suggestions for dont, wasnt or im left out don't, wasn't and I'm, or listed them behind other words, though a left-out apostrophe is the commonest slip in a contraction. Those contractions now come first, and are offered and inserted with the typographic apostrophe autocorrect types (don’t), not a straight one beside the curly quotes typed elsewhere. --- src/documents/src/lib/spelling.js | 19 ++++++++++++++++++- src/documents/src/views/proofing.js | 9 ++++++--- src/documents/tests/browser/spelling.spec.js | 16 ++++++++++++++++ src/documents/tests/spelling.test.js | 13 ++++++++++++- 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/documents/src/lib/spelling.js b/src/documents/src/lib/spelling.js index c3e2252aa..d200bcefd 100644 --- a/src/documents/src/lib/spelling.js +++ b/src/documents/src/lib/spelling.js @@ -92,6 +92,7 @@ export function rankSuggestions(word, suggestions, limit = 6) { if (candidate.length !== lower.length) score += 2; if (candidate[0] !== lower[0]) score += 3; if (s !== candidate && word === lower) score += 4; // a proper noun for a lowercase word + if (!lower.includes("'") && candidate.replaceAll("'", '') === lower) score -= 12; // a left-out apostrophe: dont, wasnt return { s, score, index }; }).sort((a, b) => a.score - b.score || a.index - b.index); const out = []; @@ -112,6 +113,22 @@ export function addPersonalWord(word) { if (!list.includes(word)) { list.push(word); try { localStorage.setItem(PERSONAL_KEY, JSON.stringify(list.slice(-5000))); } catch { /* storage full or blocked */ } } } +/** + * The words a left-out apostrophe makes of `word` (dont → don't, youre → you're, im → I'm), which the + * dictionary's own suggestions often miss although it is the commonest slip in a contraction. + */ +export function apostropheSuggestions(word, correct) { + if (word.includes("'") || word.length < 2 || word.length > 24) return []; + const out = []; + for (let at = 1; at < word.length; at++) { + const candidate = word.slice(0, at) + "'" + word.slice(at); + if (correct(candidate)) out.push(candidate); + // The pronoun I is always a capital: im → I'm, ive → I've, id → I'd, ill → I'll. + else if (at === 1 && word[0] === 'i' && correct('I' + candidate.slice(1))) out.push('I' + candidate.slice(1)); + } + return out; +} + let speller = null; /** Load the dictionary once: { correct(word), suggest(word), add(word) }. */ export function loadSpeller(base = document.baseURI) { @@ -125,7 +142,7 @@ export function loadSpeller(base = document.baseURI) { for (const word of personalWords()) engine.add(word); return { correct: word => engine.correct(word) || engine.correct(word.toLowerCase()) && word[0] === word[0].toUpperCase() && word.slice(1) === word.slice(1).toLowerCase(), - suggest: word => rankSuggestions(word, engine.suggest(word)), + suggest: word => rankSuggestions(word, [...apostropheSuggestions(word, candidate => engine.correct(candidate)), ...engine.suggest(word)]), add: word => { engine.add(word); addPersonalWord(word); }, }; })().catch(error => { speller = null; throw error; }); diff --git a/src/documents/src/views/proofing.js b/src/documents/src/views/proofing.js index 33eca27f6..b14f004bf 100644 --- a/src/documents/src/views/proofing.js +++ b/src/documents/src/views/proofing.js @@ -3,6 +3,7 @@ import { iconEl } from '../ui/icons.js'; import { engineApi } from '../lib/engine-api.js'; import { wordsToCheck, proofingText, loadSpeller } from '../lib/spelling.js'; import { proofingFingerprint, wordsInSavedRanges } from '../lib/proofing-cache.js'; +import { autocorrectEnabled } from '../lib/autocorrect.js'; const ENABLED_KEY = 'documents:spellcheck'; const LIMIT = 3000; // Past this many, a word processor stops marking and says so. @@ -162,6 +163,8 @@ export function createProofing({ editor, pkg, error, onState = () => {} }) { schedule(0); return ok; } + // A contraction is offered with the apostrophe typing gives: the typographic one while autocorrect makes quotes so. + const suggestionsFor = word => speller.suggest(word).map(option => autocorrectEnabled() ? option.replaceAll("'", '’') : option); function ignoreAll(word) { ignored.add(word); schedule(0); } function addToDictionary(word) { speller?.add(word); known.set(word, true); schedule(0); } @@ -214,7 +217,7 @@ export function createProofing({ editor, pkg, error, onState = () => {} }) { engine.model.select(current.start, current.end); engine.syncInput?.(); engine.paint(); engine.revealCaret(); engine.onSelection?.(); const text = results.text, lineStart = Math.max(text.lastIndexOf('\n', current.start) + 1, current.start - 60), lineEnd = Math.min(...[text.indexOf('\n', current.end), current.end + 60].filter(n => n >= 0)); context.replaceChildren((lineStart > 0 && text[lineStart - 1] !== '\n' ? '…' : '') + text.slice(lineStart, current.start), el('mark', {}, text.slice(current.start, current.end)), text.slice(current.end, lineEnd)); - const options = speller.suggest(current.word); + const options = suggestionsFor(current.word); suggestions.replaceChildren(...(options.length ? options : ['(no suggestions)']).map((option, i) => { const item = el('button.ed-proof-suggestion', { type: 'button', role: 'option', 'aria-selected': String(!i && !!options.length), dataset: { value: options.length ? option : '' }, disabled: !options.length, onclick: () => chooseSuggestion(item), @@ -266,7 +269,7 @@ export function createProofing({ editor, pkg, error, onState = () => {} }) { menuItems(position) { const m = enabled && !pkg()?.hideSpellingErrors && engineReady()?.session.path === null ? misspellingAt(position) : null; if (!m || !speller) return []; - const options = speller.suggest(m.word).slice(0, 5); + const options = suggestionsFor(m.word).slice(0, 5); return [ ...(options.length ? options.map(option => ({ label: option, labelStyle: { fontWeight: '600' }, disabled: !editable(), onSelect: () => replaceWord(m, option) })) : [{ label: '(no spelling suggestions)', disabled: true }]), { separator: true }, @@ -284,7 +287,7 @@ export function createProofing({ editor, pkg, error, onState = () => {} }) { await check(); return results.misspelled.filter(m => !ignoredOnce.has(`${m.start}:${m.word}`)); }, - suggest(word) { return speller?.suggest(word) || []; }, + suggest(word) { return speller ? suggestionsFor(word) : []; }, change(m, text) { return replaceWord(m, text); }, changeAll(word, text) { return replaceAll(word, text); }, ignoreOnce(m) { ignoreHere(m); schedule(0); }, diff --git a/src/documents/tests/browser/spelling.spec.js b/src/documents/tests/browser/spelling.spec.js index 3094e092f..c0d9a5058 100644 --- a/src/documents/tests/browser/spelling.spec.js +++ b/src/documents/tests/browser/spelling.spec.js @@ -164,3 +164,19 @@ test('a word ignored once stays ignored after text is typed or deleted before it await expect(input).toHaveValue(' Zorblax met the Zorblax there.'); await reviewFromStart(17); }); + +test('a word missing its apostrophe is offered the contraction, with the apostrophe autocorrect types', async ({ page }) => { + const input = await newDocument(page, 'I dont know why it wasnt there.'); + await expect(proofing(page)).toHaveAttribute('aria-label', /2 errors found/); + const menu = await wordMenu(page, input, 'dont'); + await expect(menu.getByRole('menuitem').first()).toHaveText('don’t'); + await menu.getByRole('menuitem', { name: 'don’t', exact: true }).click(); + await expect(input).toHaveValue('I don’t know why it wasnt there.'); + await input.press('F7'); + const pane = page.getByRole('complementary', { name: 'Spelling' }); + await expect(pane.locator('mark')).toHaveText('wasnt'); + await expect(pane.getByRole('option').first()).toHaveText('wasn’t'); + await pane.getByRole('button', { name: 'Change', exact: true }).click(); + await expect(input).toHaveValue('I don’t know why it wasn’t there.'); + await expect(proofing(page)).toHaveAttribute('aria-label', 'Spelling: no errors'); +}); diff --git a/src/documents/tests/spelling.test.js b/src/documents/tests/spelling.test.js index b0670384b..eea8af106 100644 --- a/src/documents/tests/spelling.test.js +++ b/src/documents/tests/spelling.test.js @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import nspell from 'nspell'; -import { wordsToCheck, rankSuggestions } from '../src/lib/spelling.js'; +import { wordsToCheck, rankSuggestions, apostropheSuggestions } from '../src/lib/spelling.js'; describe('words to check', () => { const words = text => wordsToCheck(text).map(w => w.word); @@ -51,4 +51,15 @@ describe('suggestions', () => { expect(wrong).toEqual(['Teh', 'jumpd']); expect(rankSuggestions('jumpd', speller.suggest('jumpd'))[0]).toBe('jumped'); }); + it('offers a contraction first for a word missing its apostrophe', () => { + const speller = nspell(readFileSync('public/dictionaries/en-US.aff', 'utf8'), readFileSync('public/dictionaries/en-US.dic', 'utf8')); + const suggest = word => rankSuggestions(word, [...apostropheSuggestions(word, w => speller.correct(w)), ...speller.suggest(word)]); + expect(suggest('dont')[0]).toBe("don't"); + expect(suggest('wasnt')[0]).toBe("wasn't"); + expect(suggest('youre')[0]).toBe("you're"); + expect(suggest('im')[0]).toBe("I'm"); + expect(suggest('Doesnt')[0]).toBe("Doesn't"); + expect(suggest('teh')[0]).toBe('the'); + expect(apostropheSuggestions("don't", w => speller.correct(w))).toEqual([]); + }); }); From 169191c3cd871e8ab2de60638ccfadfa17b500de Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:42:17 -0700 Subject: [PATCH 081/186] Documents: Find whole words that start or end with punctuation With Whole words on, Find took a match only where a word starts and ends, so a query beginning or ending with punctuation (Inc., C++, $100, U.S.) never matched, though the advanced search (with any More option set) found it. A match is now whole when neither of its ends falls inside a word. --- src/documents/src/engine/search.js | 10 +++++++--- src/documents/tests/canvas-search.test.js | 9 +++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/documents/src/engine/search.js b/src/documents/src/engine/search.js index 660677334..bed644871 100644 --- a/src/documents/src/engine/search.js +++ b/src/documents/src/engine/search.js @@ -89,6 +89,8 @@ function quoteFolding(query) { export function findText(paragraphs, query, { matchCase = false, wholeWord = false, skipDeleted = false } = {}) { checkedSearchText(query); if (!query) return []; + // Punctuation or spaces alone are no whole word. + if (wholeWord && ![...words.segment(query)].some(segment => segment.isWordLike)) return []; const fold = quoteFolding(query); if (fold) query = fold(query); const matchLiteral = literalMatcher(query, matchCase), simpleQuery = SIMPLE.test(query), matchSimple = simpleQuery && simpleMatcher(query, matchCase); @@ -99,11 +101,13 @@ export function findText(paragraphs, query, { matchCase = false, wholeWord = fal // Segmenting every paragraph of a long document on each search held up typing while Find was open. const simple = simpleQuery && SIMPLE.test(searched); const boundaries = simple ? null : new Set([...inlineSegments(text)].map(s => s.index).concat(text.length)); - const wordStarts = new Set(), wordEnds = new Set(); - if (wholeWord) for (const segment of words.segment(text)) if (segment.isWordLike) { wordStarts.add(segment.index); wordEnds.add(segment.index + segment.segment.length); } + // Whole words: neither end of a match falls inside a word. A query may begin or end with punctuation + // ("Inc.", "C++", "$100"), whose ends are no word's start or end but still stand apart from the words around. + const insideWords = new Set(); + if (wholeWord) for (const segment of words.segment(text)) if (segment.isWordLike) for (let i = 1; i < segment.segment.length; i++) insideWords.add(segment.index + i); let previousEnd = 0; for (const { start, end } of simple ? matchSimple(searched) : matchLiteral(searched)) { - if (start >= previousEnd && (simple || boundaries.has(start) && boundaries.has(end)) && (!wholeWord || wordStarts.has(start) && wordEnds.has(end)) && !overlaps(deleted, start, end)) { + if (start >= previousEnd && (simple || boundaries.has(start) && boundaries.has(end)) && (!wholeWord || !insideWords.has(start) && !insideWords.has(end)) && !overlaps(deleted, start, end)) { matches.push({ start: at + start, end: at + end, paragraph, offset: start, text: text.slice(start, end) }); previousEnd = end; } } diff --git a/src/documents/tests/canvas-search.test.js b/src/documents/tests/canvas-search.test.js index 4417e78ea..0fdce2e5b 100644 --- a/src/documents/tests/canvas-search.test.js +++ b/src/documents/tests/canvas-search.test.js @@ -44,6 +44,15 @@ it('uses whole-word boundaries and retries overlapping candidates rejected by a expect(find("can't can canary", 'can', whole)).toEqual([[6, 9]]); expect(find('xa a a', 'a a', whole)).toEqual([[3, 6]]); expect(find('!', '!', whole)).toEqual([]); + // A query may start or end with punctuation; its ends still stand apart from the words around it. + const text = 'Acme Inc. sells C++ for $100 in the U.S. and Inc.x too; cats. U.S.A.'; + expect(find(text, 'Inc.', whole)).toEqual([[5, 9]]); + expect(find(text, 'C++', whole)).toEqual([[16, 19]]); + expect(find(text, '$100', whole)).toEqual([[24, 28]]); + expect(find(text, 'U.S.', whole)).toEqual([[36, 40]]); + expect(find(text, 'cats.', whole)).toEqual([[56, 61]]); + expect(find(text, 'sells C++', whole)).toEqual([[10, 19]]); + expect(find(text, 'cat', whole)).toEqual([]); }); it('keeps combining sequences, emoji modifiers and joined emoji intact, without canonical normalization', () => { From fe68fcd9ab553d2cc97c51fb84dcbd0a802e2d99 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:51:32 -0700 Subject: [PATCH 082/186] Documents: Paste without formatting from the keyboard without asking to read the clipboard Ctrl+Shift+V read the clipboard through the browser's clipboard API, which asks for permission (and is refused in a frame without it, or once the user blocks it), even though the browser itself pastes as plain text for that chord. The document now takes the browser's own paste for the chord, without formatting, and reads the clipboard only where the chord pastes nothing (Cmd+Shift+V on a Mac). --- src/documents/src/engine/canvas-editor.js | 3 +++ src/documents/src/views/editor.js | 13 +++++++++- .../tests/browser/formatted-clipboard.spec.js | 26 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/documents/src/engine/canvas-editor.js b/src/documents/src/engine/canvas-editor.js index 407ebb0bd..23a48bf1b 100644 --- a/src/documents/src/engine/canvas-editor.js +++ b/src/documents/src/engine/canvas-editor.js @@ -190,9 +190,12 @@ export class CanvasEditor { listen(this.input, 'copy', e => this.copy(e, false)); listen(this.input, 'cut', e => this.copy(e, true)); listen(this.input, 'paste', e => { + // Paste without formatting (the host sets plainPaste for its chord): the text alone. + const plain = this.plainPaste; this.plainPaste = false; if (this.composition || !e.clipboardData) return; e.preventDefault(); const text = e.clipboardData.getData('text/plain').replace(/\ufffc/g, ''); + if (plain) { this.pasteData(text, ''); return; } // A copied picture or screenshot arrives as a file with no text beside it (or only an object's U+FFFC). const picture = !text && [...(e.clipboardData.files || [])].find(file => PASTED_PICTURE.test(file.type)); if (picture) { if (!this.readOnly) this.insertImageFile(picture).catch(error => this.onError(error)); return; } diff --git a/src/documents/src/views/editor.js b/src/documents/src/views/editor.js index ef9339a34..9efe1901c 100644 --- a/src/documents/src/views/editor.js +++ b/src/documents/src/views/editor.js @@ -1311,7 +1311,18 @@ export function editorView({ doc, goHome, newDoc, importFile, openPath, newFromT if (IS_MAC && e.key === 'F10' && e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey && engine && e.target === engine.input) { e.preventDefault(); openDocumentContextMenu({ event: { button: -1, clientX: 0, clientY: 0 } }); return; } - if ((e.ctrlKey || e.metaKey) && !e.altKey && e.shiftKey && shortcutCode(e) === 'KeyV' && engine && host.contains(e.target)) { e.preventDefault(); pastePlainText(); return; } + if ((e.ctrlKey || e.metaKey) && !e.altKey && e.shiftKey && shortcutCode(e) === 'KeyV' && engine && host.contains(e.target)) { + // The browser's own paste for the chord (Paste as plain text, Paste and Match Style) needs no permission to + // read the clipboard, which reading it here asks for: the document takes that paste without formatting. + // Where the chord pastes nothing, the clipboard is read instead. + if (e.target === engine.input && !pkg.readOnly) { + const editor = engine; + editor.plainPaste = true; + setTimeout(() => { if (editor.plainPaste) { editor.plainPaste = false; if (engine === editor) pastePlainText(); } }); + return; + } + e.preventDefault(); pastePlainText(); return; + } const action = formattingShortcut(e); if (action) { e.preventDefault(); action(); } }); diff --git a/src/documents/tests/browser/formatted-clipboard.spec.js b/src/documents/tests/browser/formatted-clipboard.spec.js index 32e7655c7..421287bd8 100644 --- a/src/documents/tests/browser/formatted-clipboard.spec.js +++ b/src/documents/tests/browser/formatted-clipboard.spec.js @@ -162,3 +162,29 @@ test('tabs and runs of spaces stay in the HTML a copy gives other applications', }, html); expect(pasted).toEqual(['Name\tQty (three spaces)', 'Pens\t2']); }); + +test('Paste without formatting from the keyboard needs no permission to read the clipboard', async ({ page }) => { + await blank(page); + const errors = []; page.on('pageerror', e => errors.push(e.message)); + await input(page).focus(); + await page.keyboard.type('Plain '); + await input(page).press('ControlOrMeta+b'); + await page.keyboard.type('Bold'); + await input(page).evaluate(el => { el.setSelectionRange(6, 10); el.dispatchEvent(new Event('select')); }); + // A keyboard copy writes the clipboard through its own event; no permission is granted to read it back. + await input(page).press('ControlOrMeta+c'); + await input(page).press('ControlOrMeta+End'); + await input(page).press('ControlOrMeta+b'); + await page.keyboard.type(' '); + // The chord as Windows and Linux browsers take it: Paste as plain text, through the browser's own paste. + const cdp = await page.context().newCDPSession(page); + const modifiers = process.platform === 'darwin' ? 4 | 8 : 2 | 8; + for (const type of ['rawKeyDown', 'keyUp']) await cdp.send('Input.dispatchKeyEvent', { type, key: 'V', code: 'KeyV', windowsVirtualKeyCode: 86, modifiers, ...(type === 'rawKeyDown' ? { commands: ['pasteAndMatchStyle'] } : {}) }); + await expect(input(page)).toHaveValue('Plain Bold Bold'); + expect(await state(page, 'Bold', 11)).toMatchObject({ bold: 'false' }); + await expect(page.locator('.toast')).toHaveCount(0); + // Pasted as one step. + await input(page).press('ControlOrMeta+z'); + await expect(input(page)).toHaveValue('Plain Bold '); + expect(errors).toEqual([]); +}); From 7526ba05a218193a69ebdff1b16932ca0d900728 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:54:06 -0700 Subject: [PATCH 083/186] Documents: Explain a clipboard the browser keeps closed when pasting from the menus Paste and Paste without formatting from the menus read the clipboard through the browser's clipboard API. Where the browser does not allow that (permission refused, or a frame without it), the toast showed its technical message: "Failed to execute 'readText' on 'Clipboard': Read permission denied." It now says the browser did not allow reading the clipboard and names the keys that still paste; a refused Copy or Cut names theirs. --- src/documents/src/views/editor.js | 11 ++++++++--- .../tests/browser/menu-clipboard.spec.js | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/documents/src/views/editor.js b/src/documents/src/views/editor.js index 9efe1901c..f618c3856 100644 --- a/src/documents/src/views/editor.js +++ b/src/documents/src/views/editor.js @@ -171,6 +171,11 @@ export function editorView({ doc, goHome, newDoc, importFile, openPath, newFromT const textSelected = engine.model.range[0] !== engine.model.range[1] && (!image || engine.copiesPicture()); return { cut: editable && textSelected, copy: textSelected, paste: editable && !image }; }; + // A browser that does not let the page use the clipboard (permission refused, or a frame without it) refuses + // with its own technical message; the keyboard's Cut, Copy and Paste need no permission. + const clipboardError = (e, reading) => error(e?.name === 'NotAllowedError' || e?.name === 'SecurityError' + ? new Error(reading ? `The browser didn’t allow reading the clipboard. Paste with ${MOD_KEY}+V, or ${MOD_KEY}+Shift+V for the text alone.` : `The browser didn’t allow using the clipboard. Use ${MOD_KEY}+C to copy or ${MOD_KEY}+X to cut.`) + : e); const copyText = async cut => { const editor = engine; if (!editor) return; const selection = [...editor.model.range], revision = editor.session.revision; @@ -181,7 +186,7 @@ export function editorView({ doc, goHome, newDoc, importFile, openPath, newFromT await navigator.clipboard.write([new ClipboardItem({ 'text/plain': new Blob([text], { type: 'text/plain' }), 'text/html': new Blob([html], { type: 'text/html' }) })]); } else await navigator.clipboard.writeText(text); if (cut && !destroyed && engine === editor && revision === editor.session.revision && JSON.stringify(selection) === JSON.stringify(editor.model.range)) editor.deleteSelection(); - } catch (e) { if (!destroyed) error(e); } + } catch (e) { if (!destroyed) clipboardError(e, false); } if (!destroyed) engine?.focus(); }; // Paste without formatting: the clipboard's text only, on the conventional Ctrl/Cmd+Shift+V chord. @@ -192,7 +197,7 @@ export function editorView({ doc, goHome, newDoc, importFile, openPath, newFromT const text = await navigator.clipboard.readText(); if (!destroyed && engine === editor && revision === editor.session.revision && JSON.stringify(selection) === JSON.stringify(editor.model.range)) editor.pasteData(text, ''); if (!destroyed) engine?.focus(); - } catch (e) { if (!destroyed) error(e); } + } catch (e) { if (!destroyed) clipboardError(e, true); } }; const pasteText = async () => { const editor = engine; if (!editor) return; @@ -212,7 +217,7 @@ export function editorView({ doc, goHome, newDoc, importFile, openPath, newFromT else editor.pasteData(text, html); } if (!destroyed) engine?.focus(); - } catch (e) { if (!destroyed) error(e); } + } catch (e) { if (!destroyed) clipboardError(e, true); } }; function openDocumentContextMenu({event,behind,selectBehind}) { if(!engine) return; diff --git a/src/documents/tests/browser/menu-clipboard.spec.js b/src/documents/tests/browser/menu-clipboard.spec.js index 880f36ed1..dfe332052 100644 --- a/src/documents/tests/browser/menu-clipboard.spec.js +++ b/src/documents/tests/browser/menu-clipboard.spec.js @@ -75,3 +75,19 @@ test('after a design change reopens the document, the Paste options menu still p await expect(page.getByRole('button', { name: 'Pasted text options' })).toBeVisible(); } finally { await context.close(); } }); + +test('Paste from the menus explains a clipboard the browser keeps closed, and names the keys that still paste', async ({ page }) => { + // No clipboard permission is granted: the browser refuses to let the page read it. + await page.goto('/mock.html#/new'); + await expect(page.getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + await input(page).fill('Hello'); + const menu = page.getByRole('menu', { name: 'Document menu' }); + for (const name of ['Paste', 'Paste without formatting']) { + await page.locator('canvas.canvas-page').first().click({ button: 'right', position: { x: 150, y: 110 } }); + await menu.getByRole('menuitem', { name, exact: true }).click(); + const toast = page.locator('.toast').filter({ hasText: 'clipboard' }).last(); + await expect(toast).toHaveText(/The browser didn’t allow reading the clipboard\. Paste with (Cmd|Ctrl)\+V, or (Cmd|Ctrl)\+Shift\+V for the text alone\./); + await expect(toast).not.toContainText('Failed to execute'); + } + await expect(input(page)).toHaveValue('Hello'); +}); From a68f0f1af469a2abde843406549f236592bb0bbf Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:59:09 -0700 Subject: [PATCH 084/186] Documents: Copy and Cut from the menus where the app has no clipboard access Copy and Cut from the context menu and the Edit menu wrote through the browser's clipboard API, which a frame given no clipboard access (as a desktop environment may host the app) refuses: nothing was copied, and Cut left the text in place. They now run the browser's own Copy and Cut, which send the same clipboard event as the keyboard (the same text and formats) and need no permission; the clipboard API remains the fallback. --- src/documents/src/views/editor.js | 5 ++++ .../tests/browser/menu-clipboard.spec.js | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/documents/src/views/editor.js b/src/documents/src/views/editor.js index f618c3856..3a4c9a179 100644 --- a/src/documents/src/views/editor.js +++ b/src/documents/src/views/editor.js @@ -55,6 +55,7 @@ import * as P from '../lib/puter.js'; import { printPages } from '../lib/print.js'; import { templateById } from '../lib/templates.js'; import { autocorrectEnabled, capitalizeSentencesEnabled, correction, sentenceCapital } from '../lib/autocorrect.js'; +import { execClipboardCommand } from '../lib/clipboard.js'; import { autolinkEnabled, linkCandidateBefore } from '../lib/linkify.js'; import { layoutTemplate } from '../lib/template-layout.js'; import { createWordCommands, documentStatistics as documentStatisticsOf } from './editor-commands.js'; @@ -178,6 +179,10 @@ export function editorView({ doc, goHome, newDoc, importFile, openPath, newFromT : e); const copyText = async cut => { const editor = engine; if (!editor) return; + // The browser's own Copy and Cut send the clipboard event the keyboard's do, which writes the same text and + // formats and needs no permission (a frame given no clipboard access refuses the clipboard API). + editor.focus({ paint: false }); + if (execClipboardCommand(cut ? 'cut' : 'copy')) { if (!destroyed) engine?.focus(); return; } const selection = [...editor.model.range], revision = editor.session.revision; try { // The same text and HTML a keyboard copy writes: pictures as their alternative text, not as placeholders. diff --git a/src/documents/tests/browser/menu-clipboard.spec.js b/src/documents/tests/browser/menu-clipboard.spec.js index dfe332052..3074dff0a 100644 --- a/src/documents/tests/browser/menu-clipboard.spec.js +++ b/src/documents/tests/browser/menu-clipboard.spec.js @@ -91,3 +91,31 @@ test('Paste from the menus explains a clipboard the browser keeps closed, and na } await expect(input(page)).toHaveValue('Hello'); }); + +test('Copy and Cut from the menus work in a frame given no clipboard access', async ({ page, baseURL }) => { + // A desktop environment hosts the app in a frame from another origin; this one allows it no clipboard API. + await page.goto(`${baseURL.replace('127.0.0.1', 'localhost')}/playground.html`); + await page.setContent(``); + const frame = page.frameLocator('iframe'), field = frame.getByRole('textbox', { name: 'Document text' }); + await expect(frame.getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + await field.fill('Hello brave world'); + const select = (a, b) => field.evaluate((el, [a, b]) => { el.focus(); el.setSelectionRange(a, b); el.dispatchEvent(new Event('select')); }, [a, b]); + // The menu opened on the selected text, just before its end, where the caret is drawn. + const choose = async name => { + const caret = await field.evaluate(el => ({ x: parseFloat(el.style.left) - 3, y: parseFloat(el.style.top) + parseFloat(el.style.height) / 2 })); + await frame.locator('.canvas-pages').click({ button: 'right', position: caret }); + await frame.getByRole('menu', { name: 'Document menu' }).getByRole('menuitem', { name, exact: true }).click(); + }; + await select(6, 12); + await choose('Copy'); + await select(17, 17); + await field.press('ControlOrMeta+v'); + await expect(field).toHaveValue('Hello brave worldbrave '); + await select(0, 6); + await choose('Cut'); + await expect(field).toHaveValue('brave worldbrave '); + await select(17, 17); + await field.press('ControlOrMeta+v'); + await expect(field).toHaveValue('brave worldbrave Hello '); + await expect(frame.locator('.toast').filter({ hasText: 'clipboard' })).toHaveCount(0); +}); From 8b12a96af1704fa60d143865ef4881f9441cb6a3 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:07:39 -0700 Subject: [PATCH 085/186] Documents: Capitalize the pronoun i as you type Autocorrect left a lone i lowercase ("and i think", "i'm"), which other word processors correct as soon as the word ends. It now becomes I once a space or punctuation follows it, including in I'm, I'll, I'd and I've, as its own undo step; i.e. and the list numeral (i) stay as typed. --- src/documents/src/lib/autocorrect.js | 10 ++++++++- src/documents/src/ui/appmenu.js | 2 +- src/documents/src/views/editor.js | 2 +- src/documents/tests/autocorrect.test.js | 21 +++++++++++++++++++ .../tests/browser/autocorrect.spec.js | 11 ++++++++++ 5 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/documents/src/lib/autocorrect.js b/src/documents/src/lib/autocorrect.js index 01a65b13b..0ffb45016 100644 --- a/src/documents/src/lib/autocorrect.js +++ b/src/documents/src/lib/autocorrect.js @@ -1,7 +1,8 @@ /** * Autocorrect as you type: straight quotes become * typographic ones, two hyphens between words an em dash, three periods an - * ellipsis, (c) (r) (tm) their symbols, and 1/2 1/4 3/4 real fractions. + * ellipsis, (c) (r) (tm) their symbols, 1/2 1/4 3/4 real fractions, and the + * pronoun i a capital. * * The engine inserts the typed character first; the correction then * replaces the characters just before the caret as its own step, so Undo @@ -93,6 +94,13 @@ export function correction(before, typed) { const m = /(^|[\s(“‘])([13]\/[24]) $/.exec(before); if (m && FRACTIONS[m[2]]) return { back: 4, text: `${FRACTIONS[m[2]]} ` }; } + // The pronoun I, written as a word of its own or in I'm, I'll, I'd or I've, is a capital once the word ends. A + // period ends it only with the space after it ("i.e." goes on, and "i. " opens a list item at a paragraph's + // start), and a bracket not at all (the numeral of a list item "(i)"). + if (out === typed && back === 1 && /^[\s,;:!?]$/.test(typed)) { + const m = /(?:^|[\s"“‘])(i(?:['’](?:m|ll|d|ve))?)$/.exec(prev) || (/\s/.test(typed) ? /[\s"“‘](i\.)$/.exec(prev) : null); + if (m) return { back: m[1].length + 1, text: `I${m[1].slice(1)}${typed}` }; + } return out === typed && back === 1 ? null : { back, text: out }; } diff --git a/src/documents/src/ui/appmenu.js b/src/documents/src/ui/appmenu.js index 4c6f28da0..a93373a84 100644 --- a/src/documents/src/ui/appmenu.js +++ b/src/documents/src/ui/appmenu.js @@ -40,7 +40,7 @@ export function openAppMenu(anchor, { extraItems = [] } = {}) { { separator: true }, ...extraItems, { label: 'Measurement units', icon: 'ruler', hint: unitLabel(recallUnit()), onSelect: () => openUnitsMenu(anchor) }, - { label: 'Autocorrect as you type', checked: autocorrectEnabled(), hint: 'Smart quotes, dashes, ellipses, symbols, fractions', onSelect: () => setAutocorrectEnabled(!autocorrectEnabled()) }, + { label: 'Autocorrect as you type', checked: autocorrectEnabled(), hint: 'Smart quotes, dashes, ellipses, symbols, fractions, capital I', onSelect: () => setAutocorrectEnabled(!autocorrectEnabled()) }, { label: 'Capitalize the first letter of sentences', checked: capitalizeSentencesEnabled(), onSelect: () => setCapitalizeSentencesEnabled(!capitalizeSentencesEnabled()) }, { label: 'Link web addresses as you type', checked: autolinkEnabled(), hint: 'URLs and emails become links on space or return', onSelect: () => setAutolinkEnabled(!autolinkEnabled()) }, { label: 'Keyboard shortcuts', icon: 'keyboard', onSelect: openShortcuts }, diff --git a/src/documents/src/views/editor.js b/src/documents/src/views/editor.js index 3a4c9a179..57242a120 100644 --- a/src/documents/src/views/editor.js +++ b/src/documents/src/views/editor.js @@ -968,7 +968,7 @@ export function editorView({ doc, goHome, newDoc, importFile, openPath, newFromT get: () => { const z = recallZoom(); return z?.fit || (z?.value ? String(z.value) : '100'); }, set: v => rememberZoom(v === '100' ? { value: 100 } : { fit: v }) }, ] }, { id: 'typing', label: 'AutoCorrect', note: 'These apply as you type, in every document on this device.', controls: [ - { type: 'toggle', label: 'Autocorrect as you type', hint: 'Smart quotes, dashes, ellipses, symbols and fractions; “- ” or “1. ” starts a list.', get: () => autocorrectEnabled(), set: on => setAutocorrectEnabled(on) }, + { type: 'toggle', label: 'Autocorrect as you type', hint: 'Smart quotes, dashes, ellipses, symbols, fractions and a capital I; “- ” or “1. ” starts a list.', get: () => autocorrectEnabled(), set: on => setAutocorrectEnabled(on) }, { type: 'toggle', label: 'Capitalize the first letter of sentences', get: () => capitalizeSentencesEnabled(), set: on => setCapitalizeSentencesEnabled(on) }, { type: 'toggle', label: 'Link web and email addresses', hint: 'Addresses become links when you type a space or Return after them.', get: () => autolinkEnabled(), set: on => setAutolinkEnabled(on) }, ] }, diff --git a/src/documents/tests/autocorrect.test.js b/src/documents/tests/autocorrect.test.js index 2ef17baae..9f850cea9 100644 --- a/src/documents/tests/autocorrect.test.js +++ b/src/documents/tests/autocorrect.test.js @@ -39,6 +39,27 @@ describe('autocorrect rules', () => { expect(correction('1/3 ', ' ')).toBeNull(); }); + it('capitalizes the pronoun i once its word ends', () => { + expect(correction('and i ', ' ')).toEqual({ back: 2, text: 'I ' }); + expect(correction('i,', ',')).toEqual({ back: 2, text: 'I,' }); + expect(correction('so i?', '?')).toEqual({ back: 2, text: 'I?' }); + expect(correction('then i’m ', ' ')).toEqual({ back: 4, text: 'I’m ' }); + expect(correction("then i'll ", ' ')).toEqual({ back: 5, text: "I'll " }); + expect(correction('she said “i ', ' ')).toEqual({ back: 2, text: 'I ' }); + // Not inside another word, an abbreviation, a list numeral or an address. + expect(correction('hi ', ' ')).toBeNull(); + expect(correction('ski ', ' ')).toBeNull(); + expect(correction('so do i. ', ' ')).toEqual({ back: 3, text: 'I. ' }); + expect(correction('i.', '.')).toBeNull(); + expect(correction('i. ', ' ')).toBeNull(); + expect(correction('so i.e. ', ' ')).toBeNull(); + expect(correction('(i)', ')')).toBeNull(); + expect(correction('(i ', ' ')).toBeNull(); + expect(correction('a/i ', ' ')).toBeNull(); + expect(correction('www.x.com/i ', ' ')).toBeNull(); + expect(correction('i’mx ', ' ')).toBeNull(); + }); + it('leaves ordinary typing alone', () => { expect(correction('hello', 'o')).toBeNull(); expect(correction('hello ', ' ')).toBeNull(); diff --git a/src/documents/tests/browser/autocorrect.spec.js b/src/documents/tests/browser/autocorrect.spec.js index 1c23937fe..e69cf2870 100644 --- a/src/documents/tests/browser/autocorrect.spec.js +++ b/src/documents/tests/browser/autocorrect.spec.js @@ -56,3 +56,14 @@ test('a bullet or number typed at the start of a paragraph starts a list', async await page.keyboard.press('Enter'); await page.keyboard.type('Pick - or * here'); await expect(input(page)).toHaveValue(/Pick - or \* here$/); }); + +test('the pronoun i becomes a capital once its word ends, and Undo takes that back alone', async ({ page }) => { + await blank(page); + await page.keyboard.type("yes i think i'm right, and so do i. it is (i) fine, i.e. good "); + await expect(input(page)).toHaveValue('Yes I think I’m right, and so do I. It is (i) fine, i.e. good '); + await page.keyboard.type('and i'); + await page.keyboard.type(' '); + await expect(input(page)).toHaveValue('Yes I think I’m right, and so do I. It is (i) fine, i.e. good and I '); + await input(page).press('ControlOrMeta+z'); + await expect(input(page)).toHaveValue('Yes I think I’m right, and so do I. It is (i) fine, i.e. good and i '); +}); From 8809fc0694a91cc532ddd9faf597589eb38324f2 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:17:02 -0700 Subject: [PATCH 086/186] Documents: Keep the caret in view in Text mode after Enter, Find, Go To and Undo Text mode shows the document as a text field, which scrolls to its caret only when the browser itself types there. A caret placed by the document (Enter at the foot of the field, a Find or Navigation result, Go To, Undo) could be left out of sight, so the field showed none of the match it reported. The field now scrolls to the caret's line, found in a copy of its text laid out as the field lays it out. --- src/documents/src/engine/canvas-editor.js | 36 +++++++++++++++++-- .../tests/browser/pointer-selection.spec.js | 30 ++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/documents/src/engine/canvas-editor.js b/src/documents/src/engine/canvas-editor.js index 23a48bf1b..fb8932696 100644 --- a/src/documents/src/engine/canvas-editor.js +++ b/src/documents/src/engine/canvas-editor.js @@ -1482,6 +1482,33 @@ export class CanvasEditor { this.model.select(start, start + paragraphText(this.model.paragraphs[index]).length); this.focus(); this.onSelection(); } + /** + * Text mode shows the text in the field itself, which scrolls to its caret as the browser types there, but not to + * a caret placed by a command (Enter, Undo, Find, Go To). Its line is found in a copy of the field's text up to + * the caret, laid out as the field lays it out, and the field scrolls just enough to show it. + */ + revealFieldCaret() { + // Typed text is inserted by the browser, which keeps it in view, and a deletion leaves the caret in view. + if (this.typing || this.deleting || this.composition) return; + this.syncInput(); + const input = this.input, style = getComputedStyle(input), mirror = document.createElement('div'); + for (const property of ['boxSizing', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', + 'borderStyle', 'fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'fontVariant', 'fontStretch', 'fontKerning', 'letterSpacing', 'wordSpacing', 'lineHeight', + 'textTransform', 'textIndent', 'tabSize', 'whiteSpace', 'wordBreak', 'overflowWrap']) mirror.style[property] = style[property]; + // The field's lines are as wide as its content box beside any scroll bar. + const borders = parseFloat(style.borderLeftWidth) + parseFloat(style.borderRightWidth), padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight); + Object.assign(mirror.style, { position: 'absolute', top: '0', left: '0', visibility: 'hidden', overflow: 'hidden', height: 'auto', + width: `${style.boxSizing === 'border-box' ? input.clientWidth + borders : input.clientWidth - padding}px` }); + const marker = document.createElement('span'); + marker.textContent = '\u200b'; + mirror.append(this.inputText.slice(0, this.model.selection.focus), marker); + document.body.append(mirror); + const top = marker.offsetTop, height = marker.offsetHeight || parseFloat(style.lineHeight) || 20; + mirror.remove(); + const view = input.clientHeight, padTop = parseFloat(style.paddingTop) || 0; + if (top - padTop < input.scrollTop) input.scrollTop = Math.max(0, top - padTop); + else if (top + height > input.scrollTop + view) input.scrollTop = top + height - view + (parseFloat(style.paddingBottom) || 0); + } /** Where a body position sits in the scrolling host's content box, for margin annotations. */ positionGeometry(position) { if (!this.layout || this.accessible) return null; @@ -1493,7 +1520,7 @@ export class CanvasEditor { pageLeft: canvas.offsetLeft, pageRight: canvas.offsetLeft + canvas.offsetWidth, pageTop: canvas.offsetTop, textRight: (() => { const g = this.layout.pages[caret.page] || this.layout.page; return canvas.offsetLeft + (g.width - (g.right || 0)) * this.zoom; })() }; } revealCaret() { - if (this.accessible) return; + if (this.accessible) { this.revealFieldCaret(); return; } const caret = caretRect(this.selectionLayout, this.model.selection.focus, this.caretAffinity, this.caretPage), canvas = this.canvases[caret.page]; const top = canvas.offsetTop + caret.y * this.zoom; if (top < this.host.scrollTop + 12) this.host.scrollTop = Math.max(0, top - 24); @@ -1829,8 +1856,11 @@ export class CanvasEditor { const merge = !!group && start === end && group.direction === direction && group.at === start && group.revision === this.session.revision && group.path === this.session.path; this.deleteGroup = null; this.typingGroup = null; - if (this.execute(() => this.model.delete(direction), undefined, { record: !merge })) - this.deleteGroup = { direction, at: this.model.selection.focus, revision: this.session.revision, path: this.session.path }; + // A key's deletion leaves the caret where it was or a step back, already in view in Text mode's field. + this.deleting = true; + let done; + try { done = this.execute(() => this.model.delete(direction), undefined, { record: !merge }); } finally { this.deleting = false; } + if (done) this.deleteGroup = { direction, at: this.model.selection.focus, revision: this.session.revision, path: this.session.path }; } // Corrections and links made as you type (the host decides which, from its // settings). Each is its own undo step, so Undo takes back the change alone. diff --git a/src/documents/tests/browser/pointer-selection.spec.js b/src/documents/tests/browser/pointer-selection.spec.js index ab2cf7130..a68abaf82 100644 --- a/src/documents/tests/browser/pointer-selection.spec.js +++ b/src/documents/tests/browser/pointer-selection.spec.js @@ -157,6 +157,36 @@ test('Text mode shows the text in a field that fills the editing area', async ({ expect(caret).toBeLessThan(40); }); +test('Text mode scrolls its field to a caret that Enter, Find or Undo places out of view', async ({ page }) => { + await page.goto('/mock.html#/new'); + await expect(page.getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + await input(page).fill(Array.from({ length: 120 }, (_, i) => `Paragraph ${i} of a document longer than the screen.`).join('\n') + '\nNeedle at the end.'); + await page.getByRole('checkbox', { name: 'Text mode', exact: true }).check(); + // Whether the field shows the line its caret is on, by the caret's line among the field's lines. + const caretShown = () => input(page).evaluate(el => { + // Every paragraph here fits on one line of the field. + const style = getComputedStyle(el), top = parseFloat(style.paddingTop), line = (el.scrollHeight - top - parseFloat(style.paddingBottom)) / el.value.split('\n').length; + const caretLine = el.value.slice(0, el.selectionEnd).split('\n').length - 1; + const first = Math.floor((el.scrollTop - top) / line), last = Math.floor((el.scrollTop + el.clientHeight - top) / line) - 1; + return caretLine >= first && caretLine <= last; + }); + // Enter at the foot of the field: the new line comes into view. + await input(page).evaluate(el => { el.focus(); el.setSelectionRange(el.value.length, el.value.length); el.scrollTop = el.scrollHeight; el.dispatchEvent(new Event('select')); }); + for (let i = 0; i < 4; i++) await input(page).press('Enter'); + expect(await caretShown()).toBe(true); + // Find, from the top: the match at the end comes into view. + await input(page).evaluate(el => { el.setSelectionRange(0, 0); el.scrollTop = 0; el.dispatchEvent(new Event('select')); }); + await input(page).press('ControlOrMeta+Shift+h'); + await page.getByRole('textbox', { name: 'Find text' }).fill('Needle'); + await expect(page.locator('.canvas-find-count')).toHaveText('1 of 1'); + expect(await caretShown()).toBe(true); + await page.getByRole('button', { name: 'Close find and replace' }).click(); + // Undo puts the caret back where the Enters were, at the end. + await input(page).evaluate(el => { el.scrollTop = 0; }); + await input(page).press('ControlOrMeta+z'); + expect(await caretShown()).toBe(true); +}); + test('on a Mac, Control+click keeps the selection for the context menu it opens', async ({ page }) => { await page.addInitScript(() => Object.defineProperty(Navigator.prototype, 'platform', { get: () => 'MacIntel' })); await page.goto('/mock.html#/new'); From 932a19b3e9c7fb04aa047e859fc3fcb8515f3f7d Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:20:12 -0700 Subject: [PATCH 087/186] Documents: Autocorrect letters and quotes typed with a dead key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dead key (´ then e on a Mac, or a quote key on an international layout) types through a short composition, and text committed by a composition was never corrected: él at the start of a sentence stayed lowercase and a quote stayed straight. A composition that ends in one character is now corrected and linked as a key's character is, as its own undo step; words committed by an input method are left as they are. --- src/documents/src/engine/canvas-editor.js | 8 +++-- src/documents/tests/browser/ime-input.spec.js | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/documents/src/engine/canvas-editor.js b/src/documents/src/engine/canvas-editor.js index fb8932696..d18cc432e 100644 --- a/src/documents/src/engine/canvas-editor.js +++ b/src/documents/src/engine/canvas-editor.js @@ -179,7 +179,7 @@ export class CanvasEditor { this.compositionStart = this.input.selectionStart; this.compositionText = ''; }); listen(this.input, 'compositionupdate', e => { if (this.composition) this.compositionText = e.data || ''; }); - listen(this.input, 'compositionend', () => this.finishComposition()); + listen(this.input, 'compositionend', e => this.finishComposition(e.data)); listen(this.input, 'select', () => this.nativeSelection()); listen(this.input, 'keyup', () => this.nativeSelection()); // A click in the native text (accessible text mode) moves the caret with no key or select @@ -1983,7 +1983,8 @@ export class CanvasEditor { const end = Math.min(start + this.compositionText.length, this.model.text.length); return end > start ? [start, end] : null; } - finishComposition() { + /** Commit the composition; `committed` is the text it ended with, when an input method ended it. */ + finishComposition(committed = null) { if (!this.composition) return; const before = this.composition; try { @@ -1999,6 +2000,9 @@ export class CanvasEditor { if (changed) this.session.record(before); this.relayout(); if (changed) this.onChange(); + // A dead key (´ then e, or a quote on an international layout) composes one character, which is corrected and + // linked as one typed with a key is: É at a sentence's start, a typographic quote. + if (changed && typeof committed === 'string' && [...committed].length === 1) this.assistTyping(committed); } copy(e, cut) { if (this.composition || !e.clipboardData) return; diff --git a/src/documents/tests/browser/ime-input.spec.js b/src/documents/tests/browser/ime-input.spec.js index 172f319e6..5db396de8 100644 --- a/src/documents/tests/browser/ime-input.spec.js +++ b/src/documents/tests/browser/ime-input.spec.js @@ -88,3 +88,32 @@ test('text still being composed is underlined until it is committed', async ({ p expect(await page.evaluate(() => window.__docs.current.view.engine.compositionRange())).toBeNull(); expect(await underline()).toBeGreaterThan(200); }); + +test('a letter or quote composed with a dead key is corrected as a typed one is', async ({ page }) => { + await page.goto('/mock.html#/new'); + await expect(page.getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + await input(page).focus(); + const cdp = await page.context().newCDPSession(page); + // A dead key composes its mark, then the letter (or the quote itself) that follows ends the composition. + const dead = async (mark, result) => { + await cdp.send('Input.imeSetComposition', { text: mark, selectionStart: 1, selectionEnd: 1 }); + await cdp.send('Input.insertText', { text: result }); + }; + await dead('´', 'é'); + await page.keyboard.type('l dijo. '); + await dead('´', 'á'); + await page.keyboard.type('rbol '); + await dead("'", "'"); + await page.keyboard.type('hola'); + await dead("'", "'"); + await expect(input(page)).toHaveValue('Él dijo. Árbol ‘hola’'); + // The correction is a step of its own, as for a typed quote. + await input(page).press('ControlOrMeta+z'); + await expect(input(page)).toHaveValue('Él dijo. Árbol ‘hola\''); + // A composition of several characters (a word from an input method) is left as committed. + await input(page).press('End'); + await page.keyboard.type(' '); + for (const text of ['n', 'ni', 'ni"']) await cdp.send('Input.imeSetComposition', { text, selectionStart: text.length, selectionEnd: text.length }); + await cdp.send('Input.insertText', { text: 'ni"' }); + await expect(input(page)).toHaveValue('Él dijo. Árbol ‘hola\' ni"'); +}); From b3936af3bff3168f75ba8b571046630dc23fa4fc Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:24:34 -0700 Subject: [PATCH 088/186] Documents: Keep the formatting of text copied from a table cell Any selection touching a table cell was copied as plain text only, so bold or a heading copied from within one cell lost its formatting when pasted into the body or another application. Text within one cell now copies with its formatting, as body text does; a selection across cells or into or out of a table is still copied as text. --- .../src/engine/clipboard-fragment.js | 7 ++++-- src/documents/tests/paste-modes.test.js | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/documents/src/engine/clipboard-fragment.js b/src/documents/src/engine/clipboard-fragment.js index d66fac84f..4a65a3162 100644 --- a/src/documents/src/engine/clipboard-fragment.js +++ b/src/documents/src/engine/clipboard-fragment.js @@ -28,12 +28,15 @@ const plainRun = run => !run.image && !run.math && !run.bodySymbol && !run.noteM export function copyFragment(model) { const [start, end] = model.range; if (start === end || !carriesFormatting(model)) return null; - const paragraphs = []; let at = 0, runCount = 0; + const paragraphs = []; let at = 0, runCount = 0, cell = null; for (const p of model.paragraphs) { const length = paragraphText(p).length, from = at, to = at + length; at = to + 1; if (to < start || from > end) continue; - if (p.cellId) return null; + // Text within one table cell copies as the paragraphs it is; across cells, or into or out of a table, the + // selection holds table structure the fragment cannot carry. + if (!paragraphs.length) cell = p.cellId || null; + else if ((p.cellId || null) !== cell) return null; const runs = []; let offset = from; for (const run of p.runs) { const a = Math.max(start, offset), b = Math.min(end, offset + run.text.length); diff --git a/src/documents/tests/paste-modes.test.js b/src/documents/tests/paste-modes.test.js index 05617db3a..fb8038393 100644 --- a/src/documents/tests/paste-modes.test.js +++ b/src/documents/tests/paste-modes.test.js @@ -47,3 +47,25 @@ it('keeps only emphasis when merging formatting and nothing when pasting text on expect(availablePasteModes(merge, { html })).toEqual(['keep', 'merge', 'text']); expect(availablePasteModes(merge, { html: '' })).toEqual(['text']); }); + +it('copies formatted text from within one table cell, but not a selection across cells or out of the table', () => { + const doc = documentWith(['Before']); + doc.insertTable(1, 2); + doc.replace('Plain '); doc.setRunFormat({ bold: true }); doc.replace('bold'); + doc.insertParagraph(); doc.replace('Second'); + const text = doc.text, cellStart = text.indexOf('Plain'); + doc.select(cellStart, text.indexOf('Second') + 6); + const fragment = copyFragment(doc); + expect(fragment.paragraphs.map(p => p.text)).toEqual(['Plain bold', 'Second']); + expect(fragment.paragraphs[0].runs.map(r => [r.text, r.format.bold])).toEqual([['Plain ', false], ['bold', true]]); + // Pasted into the body, the bold word stays bold. + const target = documentWith(['Target: ']); + target.select(target.text.length); + expect(pasteFragment(target, htmlFragment(fragmentHtml(fragment)))).toBe(true); + expect(target.paragraphs[0].runs.map(r => [r.text, !!r.bold])).toEqual([['Target: Plain ', false], ['bold', true]]); + // Into the next cell, or from the body into the table, the selection is not one cell's text. + doc.select(cellStart, text.indexOf('Second') + 7); + expect(copyFragment(doc)).toBeNull(); + doc.select(2, cellStart + 3); + expect(copyFragment(doc)).toBeNull(); +}); From bb18504404bcb803aec4bcbd3f1a8d5a240ec81c Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:26:02 -0700 Subject: [PATCH 089/186] Documents: Explain a closed clipboard for the Paste options too Keep Source Formatting, Merge Formatting and Keep Text Only (Home > Paste options, and the Merge Formatting chord) showed the browser's technical message when it kept the clipboard closed to the page, as the menus did. They now say the clipboard could not be read and that the keyboard still pastes, as Paste Special already did. --- src/documents/src/views/format-area.js | 8 ++++++-- src/documents/tests/browser/menu-clipboard.spec.js | 5 +++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/documents/src/views/format-area.js b/src/documents/src/views/format-area.js index 742a33159..aa9ef94c8 100644 --- a/src/documents/src/views/format-area.js +++ b/src/documents/src/views/format-area.js @@ -381,6 +381,7 @@ export function createFormatArea({ editor, pkg, error, refresh: refreshChrome = }; /* ---- Paste ---- */ + const CLIPBOARD_CLOSED = 'The clipboard could not be read. Allow clipboard access, or paste with the keyboard.'; let lastPaste = null, chip = null, original = null; async function readClipboard() { let text = '', html = '', picture = null; @@ -409,7 +410,10 @@ export function createFormatArea({ editor, pkg, error, refresh: refreshChrome = pasteWith(engine, mode, data); if (engine.session.revision !== before) remember(engine, data, mode); engine.focus(); - } catch (e) { error(e); } + } catch (e) { + // A browser that keeps the clipboard closed to the page says so in its own technical terms. + error(e?.name === 'NotAllowedError' || e?.name === 'SecurityError' ? new Error(CLIPBOARD_CLOSED) : e); + } } function remember(engine, data, mode) { lastPaste = { engine, data, mode, revision: engine.session.revision }; @@ -447,7 +451,7 @@ export function createFormatArea({ editor, pkg, error, refresh: refreshChrome = const engine = ready(); if (!engine) return; engine.finishComposition(); let data; - try { data = await readClipboard(); } catch (e) { error(new Error('The clipboard could not be read. Allow clipboard access, or paste with the keyboard.')); return; } + try { data = await readClipboard(); } catch (e) { error(new Error(CLIPBOARD_CLOSED)); return; } const formatted = availablePasteModes(engine.model, data).includes('keep'); const choices = [ ...(formatted ? [{ id: 'keep', label: 'HTML format', hint: 'Text with the source’s fonts, styles and lists' }, { id: 'merge', label: 'Formatted text (merged)', hint: 'Bold, italic and underline only, in this document’s formatting' }] : []), diff --git a/src/documents/tests/browser/menu-clipboard.spec.js b/src/documents/tests/browser/menu-clipboard.spec.js index 3074dff0a..36cd5b0fc 100644 --- a/src/documents/tests/browser/menu-clipboard.spec.js +++ b/src/documents/tests/browser/menu-clipboard.spec.js @@ -89,6 +89,11 @@ test('Paste from the menus explains a clipboard the browser keeps closed, and na await expect(toast).toHaveText(/The browser didn’t allow reading the clipboard\. Paste with (Cmd|Ctrl)\+V, or (Cmd|Ctrl)\+Shift\+V for the text alone\./); await expect(toast).not.toContainText('Failed to execute'); } + // The Paste options on the Home tab say the same. + await page.getByRole('button', { name: 'Paste options', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Keep Text Only', exact: true }).click(); + const toast = page.locator('.toast').filter({ hasText: 'clipboard' }).last(); + await expect(toast).toHaveText('The clipboard could not be read. Allow clipboard access, or paste with the keyboard.'); await expect(input(page)).toHaveValue('Hello'); }); From c49958c8146d7161071f30009803c674ec5950aa Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:34:38 -0700 Subject: [PATCH 090/186] Documents: Search the main document while a header, footer or note is being edited The Navigation pane's search, Find and Replace's Main document and Find Next searched the story being edited instead: with the caret in a footer, text plainly in the body was "not found", and Replace All changed only the footer. They now search the body, a result leaves the header or footer for it, and Replace All in the main document replaces there; Current selection still searches the selection in the story being edited. --- src/documents/src/views/edit-commands.js | 9 +++-- src/documents/src/views/editor.js | 7 +++- src/documents/src/views/find-replace.js | 2 +- src/documents/src/views/format-area.js | 16 ++++++-- .../tests/browser/find-replace.spec.js | 39 +++++++++++++++++++ 5 files changed, 64 insertions(+), 9 deletions(-) diff --git a/src/documents/src/views/edit-commands.js b/src/documents/src/views/edit-commands.js index e574a64c1..d97cfb35e 100644 --- a/src/documents/src/views/edit-commands.js +++ b/src/documents/src/views/edit-commands.js @@ -81,13 +81,16 @@ export function createEditCommands({ editor, pkg, editLink, searchQuery = () => /** Find Next / Previous: the next match of the last search after (or before) the selection. */ findNext(direction = 1) { const engine = editor(); if (!engine) return false; - const [start, end] = engine.model.range; - const query = searchQuery() || lastQuery || (end > start && end - start <= 255 ? engine.model.text.slice(start, end) : ''); + const [selectedStart, selectedEnd] = engine.model.range; + const query = searchQuery() || lastQuery || (selectedEnd > selectedStart && selectedEnd - selectedStart <= 255 ? engine.model.text.slice(selectedStart, selectedEnd) : ''); if (!query || /[\n\f\v]/.test(query)) { toast('Search for something first (Find).', { icon: 'search', duration: 2500 }); return false; } lastQuery = query; + // The search, as the Navigation pane's, is of the body, also while a header, footer or note is being edited. + const body = engine.session.body || engine.model, [start, end] = body.range; let matches = []; - try { matches = engine.model.findMatches(query, {}); } catch { matches = []; } + try { matches = body.findMatches(query, {}); } catch { matches = []; } if (!matches.length) { toast(`“${query}” was not found.`, { icon: 'search', duration: 2500 }); return false; } + if (engine.session.path !== null) { engine.finishComposition(); engine.editStory(null); } const next = direction > 0 ? matches.find(m => m.start >= end && !(m.start === start && m.end === end)) || matches[0] : [...matches].reverse().find(m => m.end <= start && !(m.start === start && m.end === end)) || matches.at(-1); engine.selectMatch(next); engine.focus(); diff --git a/src/documents/src/views/editor.js b/src/documents/src/views/editor.js index 57242a120..5666426ce 100644 --- a/src/documents/src/views/editor.js +++ b/src/documents/src/views/editor.js @@ -500,9 +500,12 @@ export function editorView({ doc, goHome, newDoc, importFile, openPath, newFromT engine.focus(); engine.revealCaret(); engine.onSelection(); }, pages: () => engine?.canvases || [], flushPages: () => engine?.flushPaint?.(), currentPage: () => engine?.caretPageIndex ?? 0, - search: text => { try { return engine?.model.findMatches(text, {}) || []; } catch { return []; } }, + // The search covers the document's body, also while a header, footer or note is being edited. + search: text => { try { return engine?.session.body.findMatches(text, {}) || []; } catch { return []; } }, version: () => (engine ? [engine, engine.session.revision, engine.session.body.revision, engine.session.path, engine.model, engine.model.revision, engine.model.trackChanges] : null), - text: () => engine?.model.text || '', selectMatch: match => engine?.selectMatch(match), highlight: matches => engine?.setFindHighlights(matches), + text: () => engine?.session.body.text || '', + selectMatch: match => { if (!engine) return; engine.finishComposition(); if (engine.session.path !== null) engine.editStory(null); engine.selectMatch(match); }, + highlight: matches => engine?.setFindHighlights(engine.session.path === null ? matches : []), headingActions: () => editorCommands.headingActions || null }); outlinePanel.node.id = 'canvas-outline-panel'; function toggleOutline(visible = outlinePanel.node.hidden, { search = false } = {}) { diff --git a/src/documents/src/views/find-replace.js b/src/documents/src/views/find-replace.js index 3b4396065..bdda0cd76 100644 --- a/src/documents/src/views/find-replace.js +++ b/src/documents/src/views/find-replace.js @@ -139,7 +139,7 @@ export function findReplacePanel(api) { catch (e) { error.textContent = e.message; error.hidden = false; } } highlight(matches); - const [start, end] = api.range(), current = matches.findIndex(m => m.start === start && m.end === end && (!m.story || api.isCurrentStory?.(m.story))); + const [start, end] = api.range(), current = matches.findIndex(m => m.start === start && m.end === end && (api.isCurrentStory ? api.isCurrentStory(m.story || null) : !m.story)); count.textContent = !searching ? 'Enter text to find' : !matches.length ? 'No matches' : current < 0 ? `${matches.length} ${matches.length === 1 ? 'match' : 'matches'}` : `${current + 1} of ${matches.length}`; previous.disabled = next.disabled = !matches.length; replacement.disabled = !!api.readOnly?.(); diff --git a/src/documents/src/views/format-area.js b/src/documents/src/views/format-area.js index aa9ef94c8..c5e9a8464 100644 --- a/src/documents/src/views/format-area.js +++ b/src/documents/src/views/format-area.js @@ -344,11 +344,18 @@ export function createFormatArea({ editor, pkg, error, refresh: refreshChrome = const storyParagraphs = (engine, path) => engine.session.models?.get(path)?.paragraphs || engine.pageStories.stories[path].paragraphs; const findApi = { scopes: () => [['headers', 'Headers and footers']], - isCurrentStory: story => editor()?.session.path === story.path, + // A match without a story is in the body. + isCurrentStory: story => editor()?.session.path === (story ? story.path : null), range: () => editor()?.model.range || [0, 0], matches(query, options) { const engine = editor(); if (!engine) return []; - if (options.scope !== 'headers') return engine.model.findMatches(query, options); + // The current selection is in the story being edited; the main document is the body, also while a header, + // footer or note is being edited. + if (options.scope === 'selection') { + const found = engine.model.findMatches(query, options), path = engine.session.path; + return path === null ? found : found.map(m => ({ ...m, story: { path, page: engine.storyPage } })); + } + if (options.scope !== 'headers') return engine.session.body.findMatches(query, options); const body = engine.session.body || engine.model; return searchStories(engine).flatMap(story => findAdvanced(storyParagraphs(engine, story.path), query, { ...options, within: undefined }, { styles: body.styles, numbering: body.numbering }).map(m => ({ ...m, story }))); }, @@ -361,7 +368,10 @@ export function createFormatArea({ editor, pkg, error, refresh: refreshChrome = highlight(matches) { const engine = editor(); engine?.setFindHighlights(matches.filter(m => (m.story?.path ?? null) === engine.session.path)); }, replace(query, text, options, all) { const engine = editor(); if (!engine) return 0; - if (options.scope !== 'headers') return engine.replaceMatches(query, text, options, all); + if (options.scope !== 'headers') { + if (options.scope !== 'selection' && engine.session.path !== null) engine.editStory(null); + return engine.replaceMatches(query, text, options, all); + } if (!all) return engine.session.path === null ? 0 : engine.replaceMatches(query, text, options, false); let total = 0; for (const story of searchStories(engine)) { diff --git a/src/documents/tests/browser/find-replace.spec.js b/src/documents/tests/browser/find-replace.spec.js index acf7ea28a..bb675950d 100644 --- a/src/documents/tests/browser/find-replace.spec.js +++ b/src/documents/tests/browser/find-replace.spec.js @@ -179,3 +179,42 @@ test('tints every match while searching, leaves the current one in the selection await input.press('ControlOrMeta+End'); await page.keyboard.insertText(' needle'); await expect(count(page)).toHaveText('4 matches'); await expect.poll(tinted).toBeGreaterThan(two * 1.4); await close(page); await expect.poll(tinted).toBe(0); await expect(page.getByRole('button', { name: 'Undo', exact: true })).toBeEnabled(); }); + +test('searches and replaces in the main document while a footer is being edited', async ({ page }) => { + await page.goto('/mock.html#/new'); + await expect(page.getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + await page.getByLabel('Open DOCX file').setInputFiles('tests/fixtures/page-stories/footer-variants.docx'); + const body = page.getByRole('textbox', { name: 'Document text' }); + await expect(body).toHaveValue('Body page 1\nBody page 2\nBody page 3'); + const story = () => page.evaluate(() => window.__docs.current.view.engine.session.path); + const editFooter = async () => { await page.evaluate(() => window.__docs.current.view.engine.editStory('word/footer-first.xml', 0)); await expect.poll(story).toBe('word/footer-first.xml'); }; + await editFooter(); + // The Navigation pane lists the body's matches, and a result goes to the body. + await page.getByRole('textbox', { name: 'Footer text' }).press('ControlOrMeta+f'); + const search = page.getByRole('searchbox', { name: 'Search document' }); + await search.fill('page'); + const results = page.getByRole('list', { name: 'Search results' }).getByRole('button'); + await expect(results).toHaveCount(3); + await results.nth(1).click(); + await expect.poll(story).toBe(null); + expect(await body.evaluate(el => el.value.slice(el.selectionStart, el.selectionEnd) + '@' + el.selectionStart)).toBe('page@17'); + // Find and Replace in the main document, started from the footer. + await editFooter(); + await page.getByRole('textbox', { name: 'Footer text' }).press(REPLACE_CHORD); + await query(page).fill('Body'); + // The first match is selected, in the body. + await expect(count(page)).toHaveText('1 of 3'); + await expect.poll(story).toBe(null); + await editFooter(); + await expect(count(page)).toHaveText('3 matches'); + await replacement(page).fill('Main'); + await panel(page).getByRole('button', { name: 'Replace all', exact: true }).click(); + await expect(body).toHaveValue('Main page 1\nMain page 2\nMain page 3'); + expect(await page.evaluate(() => window.__docs.current.view.engine.pageStories.stories['word/footer-first.xml'].paragraphs.map(p => p.runs.map(r => r.text).join('')).join('\n'))).toBe('First \ufffc'); + // Find Next (Shift+F4) goes on with the Navigation pane's search, in the body. + await close(page); + await editFooter(); + await page.getByRole('textbox', { name: 'Footer text' }).press('Shift+F4'); + await expect.poll(story).toBe(null); + expect(await body.evaluate(el => el.value.slice(el.selectionStart, el.selectionEnd))).toBe('page'); +}); From 30fce16ff6a4fccdb9592817150e14e4d512c299 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:36:45 -0700 Subject: [PATCH 091/186] Documents: Go To from a header or footer counts from its page Go To (F5) took the caret's offset in the header or footer being edited as a place in the body, so Next, +1 and -1 counted from near the start of the document: from the footer of page 2, Next page went to page 2 again instead of page 3. A header or footer now counts as being on the page it is edited on. --- src/documents/src/views/go-to.js | 3 ++- .../tests/browser/keyboard-shortcuts.spec.js | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/documents/src/views/go-to.js b/src/documents/src/views/go-to.js index 07a6ce9e3..96b6b84f5 100644 --- a/src/documents/src/views/go-to.js +++ b/src/documents/src/views/go-to.js @@ -47,7 +47,8 @@ export function openGoTo(engine, { onClose } = {}) { const message = el('p.field-error', { role: 'alert', hidden: true }); const previous = el('button.btn.btn-outline', { type: 'button', onclick: () => step(-1) }, 'Previous'); const next = el('button.btn.btn-primary', { type: 'submit' }, 'Next'); - const current = () => engine.model.range[0]; + // In a header or footer, where it is is the page it is on: its own offsets are not the body's. + const current = () => engine.session.path === null ? engine.model.range[0] : goToTargets(engine, 'page')[engine.storyPage ?? 0]?.position ?? 0; function refresh() { const name = KINDS.find(([value]) => value === kind.value)[1]; const bookmark = kind.value === 'bookmark'; diff --git a/src/documents/tests/browser/keyboard-shortcuts.spec.js b/src/documents/tests/browser/keyboard-shortcuts.spec.js index 0f0482a9f..a51ca2320 100644 --- a/src/documents/tests/browser/keyboard-shortcuts.spec.js +++ b/src/documents/tests/browser/keyboard-shortcuts.spec.js @@ -108,3 +108,23 @@ test('Go To jumps to pages, headings and bookmarks, and the Breaks menu inserts await page.getByRole('menuitem', { name: 'Column' }).click(); expect(await input.inputValue()).toContain('Page 2 \ntext'); }); + +test('Go To counts from the page of the footer being edited', async ({ page }) => { + await page.goto('/mock.html#/new'); + await expect(page.getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + await page.getByLabel('Open DOCX file').setInputFiles('tests/fixtures/page-stories/footer-variants.docx'); + const body = page.getByRole('textbox', { name: 'Document text' }); + await expect(body).toHaveValue('Body page 1\nBody page 2\nBody page 3'); + const dialog = page.getByRole('dialog', { name: 'Go To' }); + for (const [move, expected] of [['', 'Body page 3'], ['+1', 'Body page 3'], ['-1', 'Body page 1']]) { + // The footer of the second page. + await page.evaluate(() => window.__docs.current.view.engine.editStory('word/footer-even.xml', 1)); + const footer = page.getByRole('textbox', { name: 'Footer text' }); + await footer.press('F5'); + await dialog.getByLabel('Enter page number').fill(move); + await dialog.getByRole('button', { name: move ? 'Go To' : 'Next' }).click(); + if (!move) await dialog.getByRole('button', { name: 'Close' }).last().click(); + await expect(dialog).toHaveCount(0); + expect(await body.evaluate(el => el.value.slice(el.selectionStart, el.selectionStart + 11))).toBe(expected); + } +}); From bab0ae24f7ae361e4021d30d48a9fdc3ea9f83a5 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:25:55 -0700 Subject: [PATCH 092/186] Documents: Edit the header or footer a page shows when its page number is even With different odd and even pages, Edit Header/Footer, the header and footer designs and Page Number > Top/Bottom of Page chose the even or odd story by the sheet's position, while layout chooses it by the page's number. On a document numbered from 2 (or any section whose numbering flips the parity), they opened the story the page does not show, so typing and page numbers went into the other variant. The variant now comes from the same page-number sequence layout uses. --- src/documents/src/views/references.js | 18 +++++--- .../browser/header-footer-variant.spec.js | 42 +++++++++++++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 src/documents/tests/browser/header-footer-variant.spec.js diff --git a/src/documents/src/views/references.js b/src/documents/src/views/references.js index 97e4f3740..225a8d661 100644 --- a/src/documents/src/views/references.js +++ b/src/documents/src/views/references.js @@ -28,12 +28,18 @@ export function createReferences({ editor, pkg, error, base = {}, onChange = () const ready = () => { const engine = editor(); return engine && !pkg()?.readOnly ? engine : null; }; const fail = e => { error(e); editor()?.focus(); }; - /** Which header/footer variant a page shows: first, even or default (§17.10.5). */ - function variantFor(engine, page) { - const definition = engine.pageStories, section = engine.sectionOfPage(page); - const firstPage = engine.layout.pageSections?.indexOf(section) ?? 0; + /** + * Which header/footer variant a page shows: first, even or default (§17.10.5). Even and odd go by the + * page's number, as layout chooses them: a section numbered from 2 shows its even story on its first sheet. + */ + async function variantFor(engine, page) { + const definition = engine.pageStories, section = engine.sectionOfPage(page), layout = engine.layout; + const firstPage = layout.pageSections?.indexOf(section) ?? 0; if (page === firstPage && definition?.sections[section]?.firstPageDifferent) return 'first'; - return definition?.evenAndOdd && (page + 1) % 2 === 0 ? 'even' : 'default'; + if (!definition?.evenAndOdd) return 'default'; + const { pageNumberSequence } = await import('../engine/page-numbering.js'); + const number = pageNumberSequence(layout.pageSections || [], definition, layout.numberingApproximations)[page] ?? page + 1; + return number % 2 === 0 ? 'even' : 'default'; } const currentPage = engine => engine.session.path !== null ? engine.storyPage ?? 0 : engine.caretPageIndex ?? 0; @@ -42,7 +48,7 @@ export function createReferences({ editor, pkg, error, base = {}, onChange = () const engine = ready(); if (!engine) return null; engine.finishComposition(); page ??= currentPage(engine); - const section = engine.sectionOfPage(page), variant = variantFor(engine, page); + const section = engine.sectionOfPage(page), variant = await variantFor(engine, page); const existing = engine.pageStories?.sections[section]?.[kind]?.[variant]; if (existing) { if (!engine.pageStories.stories[existing]?.editable) throw new Error(`This document's ${kind} cannot be edited yet.`); diff --git a/src/documents/tests/browser/header-footer-variant.spec.js b/src/documents/tests/browser/header-footer-variant.spec.js new file mode 100644 index 000000000..8272b48b3 --- /dev/null +++ b/src/documents/tests/browser/header-footer-variant.spec.js @@ -0,0 +1,42 @@ +import { test, expect } from '@playwright/test'; +import { onTab } from './helpers/editor-tools.js'; + +// With different odd and even pages, a page shows the even footer when its page NUMBER is even +// (§17.10.5), whatever its position in the file. The offset fixture's first page is numbered ii. +const FIXTURE = 'public/fixtures/section-page-numbers-offset.docx'; +const engine = page => page.evaluate(() => { + const e = window.__docs.current.view.engine; + const shown = e.storyLayout.lines.find(line => line.page === 0 && e.pageStories.stories[line.pageStory]?.kind === 'footer')?.pageStory; + return { active: e.session.path, shown, body: e.session.body.text }; +}); + +async function open(page) { + await page.goto('/mock.html'); + const input = page.getByRole('textbox', { name: 'Document text' }); + await expect(input).toBeVisible(); + await page.getByLabel('Open DOCX file').setInputFiles(FIXTURE); + await expect(input).toHaveValue(/^Roman start, page 1/); + await expect(input).toHaveJSProperty('readOnly', false); + await page.evaluate(() => { const e = window.__docs.current.view.engine; e.model.select(0); e.focus(); }); +} + +test('Edit Footer opens the footer the page shows when its number is even', async ({ page }) => { + await open(page); + const before = await engine(page); + expect(before.shown).toBe('word/footer-even.xml'); + await onTab(page, 'insert'); + await page.locator('#ribbon-panel-insert').getByRole('button', { name: 'Footer', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Edit Footer', exact: true }).click(); + await expect.poll(async () => (await engine(page)).active).toBe('word/footer-even.xml'); +}); + +test('a page number at the bottom of an even-numbered page goes into the footer that page shows', async ({ page }) => { + await open(page); + const body = (await engine(page)).body; + await onTab(page, 'insert'); + await page.locator('#ribbon-panel-insert').getByRole('button', { name: 'Page Number', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Bottom of Page', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Plain Number 1: Left' }).click(); + await expect.poll(async () => (await engine(page)).active).toBe('word/footer-even.xml'); + expect((await engine(page)).body).toBe(body); +}); From 6abc5d1de0d95f54eee6128dbf01706f08999f32 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:27:14 -0700 Subject: [PATCH 093/186] Documents: Keep the caret in place when fields update Update Fields (F9) selected each field result to replace it, so afterwards the caret sat at the end of the first field it had updated, wherever it was before. The selection is now carried across the replaced results and put back. --- src/documents/src/engine/field-update.js | 11 +++++ .../tests/field-update-selection.test.js | 43 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 src/documents/tests/field-update-selection.test.js diff --git a/src/documents/src/engine/field-update.js b/src/documents/src/engine/field-update.js index a5d78ce79..069d34e13 100644 --- a/src/documents/src/engine/field-update.js +++ b/src/documents/src/engine/field-update.js @@ -132,6 +132,11 @@ export function fieldUpdatePlan(model, { onIssue = () => {}, notes = null } = {} export function updateSequenceFields(model, options) { const tracking = model.trackChanges; model.trackChanges = false; const updated = new Set(); + // The selection stays on the text it was on: each result is selected to replace it, which moved the + // caret to the last field updated (the first in the document) on every F9. + const { anchor: anchorBefore, focus: focusBefore, controlCaret, controlSelection } = model.selection; + let anchor = anchorBefore, focus = focusBefore; + const typing = { typingFormat: model.typingFormat, typingStyle: model.typingStyle, typingDirectCleared: model.typingDirectCleared }; try { // A cross-reference shows the text of what it refers to, which a SEQ field in this same pass may change: // plan again after applying, until nothing changes (a few passes; references do not refer to references). @@ -140,6 +145,8 @@ export function updateSequenceFields(model, options) { for (const { range, text } of [...fieldUpdatePlan(model, pass ? { ...options, onIssue: () => {} } : options)].reverse()) { // A result never spans a paragraph mark: replacing one that does would merge paragraphs. if (model.text.slice(range.start, range.end).includes('\n')) continue; + const map = at => at <= range.start ? at : at >= range.end ? at + text.length - (range.end - range.start) : range.start + Math.min(at - range.start, text.length); + anchor = map(anchor); focus = map(focus); model.select(range.start, range.end); model.typingFormat = {}; model.typingStyle = null; model.typingDirectCleared = false; model.replaceResult(text, { checkpoint: false }); @@ -147,6 +154,10 @@ export function updateSequenceFields(model, options) { } if (!applied) break; } + if (updated.size) { + model.select(anchor, focus, ...(anchor === anchorBefore && focus === focusBefore ? [controlCaret, controlSelection] : [])); + Object.assign(model, typing); + } } finally { model.trackChanges = tracking; } if (updated.size) model.didChange(); return updated.size; diff --git a/src/documents/tests/field-update-selection.test.js b/src/documents/tests/field-update-selection.test.js new file mode 100644 index 000000000..d093b30a3 --- /dev/null +++ b/src/documents/tests/field-update-selection.test.js @@ -0,0 +1,43 @@ +import { beforeAll, afterAll, it, expect } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { DocxPackage, createDocx, WRITING_STYLES } from '../src/engine/docx.js'; +import { TextDocument, paragraph } from '../src/engine/model.js'; +import { insertCaption, referenceTargets, insertCrossReference } from '../src/engine/captions.js'; +import { updateSequenceFields } from '../src/engine/field-update.js'; + +let dom; +beforeAll(() => { dom = new JSDOM(); globalThis.DOMParser = dom.window.DOMParser; globalThis.XMLSerializer = dom.window.XMLSerializer; }); +afterAll(() => dom.window.close()); + +async function captionedDocument() { + const blank = createDocx([paragraph()], undefined, undefined, undefined, [], {}, WRITING_STYLES); + const pkg = await DocxPackage.open(new Uint8Array(await blank.export().arrayBuffer())); + const d = new TextDocument(pkg.paragraphs, pkg.pageSettings, pkg.numbering, pkg.styles, pkg.tables, pkg.assets); + d.replace('Chart here\nSee the reference: \nFinal paragraph with more words'); + d.select(3); insertCaption(d, { label: 'Figure', text: 'Sales' }); + d.select(d.text.indexOf('reference: ') + 11); insertCrossReference(d, referenceTargets(d).captions[0], { show: 'entire' }); + // The caption's text changes after the cross-reference was made: its result is out of date. + d.select(d.text.indexOf('Sales') + 2); d.replace(' data'); + return d; +} + +it('keeps the caret where it was when fields update (F9)', async () => { + const d = await captionedDocument(); + const end = d.text.length; + d.select(end); + expect(updateSequenceFields(d)).toBe(1); + expect(d.text).toContain('See the reference: Figure 1: Sa datales\n'); + // The result before the caret grew by five characters; the caret stays at the end of the document. + expect(d.range).toEqual([d.text.length, d.text.length]); +}); + +it('keeps a selection before and after updated fields in place', async () => { + const d = await captionedDocument(); + const start = d.text.indexOf('Chart'), end = d.text.indexOf('Final') + 5; + d.select(start, end); + updateSequenceFields(d); + expect(d.text.slice(...d.range)).toBe(d.text.slice(d.text.indexOf('Chart'), d.text.indexOf('Final') + 5)); + d.select(1); + updateSequenceFields(d); + expect(d.range).toEqual([1, 1]); +}); From 16d4618953c8716bef77c68d68e6f3a9fc4d7952 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:29:06 -0700 Subject: [PATCH 094/186] Documents: Refuse bookmark names that would hide the bookmark from the Bookmarks list The Bookmarks dialog accepted a name starting with an underscore, but such names mark hidden bookmarks (the ones tables of contents and cross-references make) and the list leaves them out: the new bookmark disappeared and could no longer be renamed, deleted or gone to from the dialog. The dialog now asks for a name that starts with a letter. --- src/documents/src/views/bookmarks.js | 10 +++++++--- src/documents/tests/browser/bookmarks.spec.js | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/documents/src/views/bookmarks.js b/src/documents/src/views/bookmarks.js index 1200e9244..951185013 100644 --- a/src/documents/src/views/bookmarks.js +++ b/src/documents/src/views/bookmarks.js @@ -7,8 +7,12 @@ export function openBookmarks({ entries, add, rename, remove, navigate }) { const list = el('select.input', { 'aria-label': 'Document bookmarks', size: 6, style: { backgroundImage: 'none' } }); const error = el('p.field-error', { role: 'alert', hidden: true }); const validate = action => { - try { checkedBookmarkName(name.value); if (entries().some(e => e.name === name.value && (action === 'add' || e.id !== list.value))) throw new Error('A bookmark with that name already exists.'); } - catch (e) { error.hidden = false; error.textContent = e.message; return; } + try { + checkedBookmarkName(name.value); + // Names starting with an underscore are hidden bookmarks (tables of contents, cross-references), which this list leaves out. + if (name.value.startsWith('_')) throw new Error('Start the name with a letter. Names that start with an underscore are hidden bookmarks.'); + if (entries().some(e => e.name === name.value && (action === 'add' || e.id !== list.value))) throw new Error('A bookmark with that name already exists.'); + } catch (e) { error.hidden = false; error.textContent = e.message; return; } if (action === 'add' ? add(name.value) : rename(list.value, name.value)) { refresh(); error.hidden = true; } }; const renameButton = el('button.btn', { type: 'button', onclick: () => validate('rename') }, 'Rename'); @@ -19,7 +23,7 @@ export function openBookmarks({ entries, add, rename, remove, navigate }) { list.addEventListener('change', update); refresh(); const content = el('form.bookmark-form', { onsubmit: e => { e.preventDefault(); validate('add'); } }, el('label.field', {}, el('span.field-label', {}, 'Name'), name), - el('p.field-hint', {}, 'Add a bookmark at the caret or around selected text. Use 1–40 letters, numbers or underscores; start with a letter or underscore.'), error, + el('p.field-hint', {}, 'Add a bookmark at the caret or around selected text. Use 1–40 letters, numbers or underscores, starting with a letter.'), error, el('div.row', {}, el('button.btn.btn-primary', { type: 'submit' }, 'Add bookmark'), renameButton), el('label.field', {}, el('span.field-label', {}, 'Bookmarks'), list), el('div.row.row-end', {}, go, removeButton, el('button.btn.btn-ghost', { type: 'button', onclick: () => modal.close() }, 'Done'))); diff --git a/src/documents/tests/browser/bookmarks.spec.js b/src/documents/tests/browser/bookmarks.spec.js index 80df2833e..098646f47 100644 --- a/src/documents/tests/browser/bookmarks.spec.js +++ b/src/documents/tests/browser/bookmarks.spec.js @@ -46,3 +46,21 @@ test('previews unmatched and reversed bookmark structures as read-only with the test('table deletion removes contained bookmarks while retaining the surviving part of cross-table ranges and undo restores them',async({page})=>{ await page.goto('/playground.html'); await showAllTools(page);await page.getByLabel('Open DOCX file').setInputFiles('public/fixtures/bookmarks.docx');const input=page.getByRole('textbox',{name:'Document text'});await expect(input).toHaveValue(/^Bookmarks and internal links/);const text=await input.inputValue();await choose(input,'Cell target');await page.getByRole('combobox',{name:'Table actions',exact:true}).selectOption('table');await manager(page);const list=page.getByRole('listbox',{name:'Document bookmarks',exact:true});await expect(list.locator('option').filter({hasText:/^CellTarget ·/})).toHaveCount(0);await selectBookmark(page,'IntoTable');await page.getByRole('dialog').getByRole('button',{name:'Go to bookmark',exact:true}).click();await expect(page.getByRole('dialog')).toHaveCount(0);expect(await input.evaluate(el=>el.selectionStart)).toBe(text.indexOf('Before the table'));await page.getByRole('button',{name:'Undo',exact:true}).click();await expect(input).toHaveValue(text);await manager(page);await selectBookmark(page,'CellTarget');await page.getByRole('dialog').getByRole('button',{name:'Go to bookmark',exact:true}).click();await expect(page.getByRole('dialog')).toHaveCount(0);expect(await input.evaluate(el=>el.selectionStart)).toBe(text.indexOf('Cell target')); }); + +test('refuses a bookmark name starting with an underscore, which would be hidden from the list',async({page})=>{ + await page.goto('/playground.html'); await showAllTools(page);await page.getByRole('button',{name:'New',exact:true}).click(); + const input=page.getByRole('textbox',{name:'Document text'});await input.fill('Target text');await choose(input,'Target'); + await manager(page);await page.getByRole('textbox',{name:'Bookmark name',exact:true}).fill('_draft'); + await page.getByRole('dialog').getByRole('button',{name:'Add bookmark',exact:true}).click(); + await expect(page.getByRole('dialog').getByRole('alert')).toContainText('Start the name with a letter'); + await expect(page.getByRole('listbox',{name:'Document bookmarks',exact:true}).locator('option')).toHaveCount(0); + // A name with a letter first is added and listed. + await page.getByRole('textbox',{name:'Bookmark name',exact:true}).fill('Draft_1'); + await page.getByRole('dialog').getByRole('button',{name:'Add bookmark',exact:true}).click(); + await expect(page.getByRole('listbox',{name:'Document bookmarks',exact:true}).locator('option')).toHaveText([/^Draft_1 ·/]); + // Renaming to a hidden name is refused the same way. + await selectBookmark(page,'Draft_1');await page.getByRole('textbox',{name:'Bookmark name',exact:true}).fill('_hidden'); + await page.getByRole('dialog').getByRole('button',{name:'Rename',exact:true}).click(); + await expect(page.getByRole('dialog').getByRole('alert')).toContainText('Start the name with a letter'); + await expect(page.getByRole('listbox',{name:'Document bookmarks',exact:true}).locator('option')).toHaveText([/^Draft_1 ·/]); +}); From fb2ee0db72250ad36edd6630ac00f82c3dab3cb4 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:33:11 -0700 Subject: [PATCH 095/186] Documents: Edit documents whose page numbers use the ARABIC switch, as LibreOffice saves them LibreOffice writes page numbers as PAGE \* ARABIC. The general format switch was matched case-sensitively, so ARABIC (or arabic) was refused as an unsupported switch and the whole document opened read-only. Arabic and ArabicDash are now read in any letter case; roman and alphabetic keep their exact spellings, whose case chooses the result's. --- src/documents/docs/conformance.md | 4 +++- src/documents/src/engine/docx-page-fields.js | 5 ++++- src/documents/tests/page-fields.test.js | 11 +++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/documents/docs/conformance.md b/src/documents/docs/conformance.md index 34ea17db1..23a42610b 100644 --- a/src/documents/docs/conformance.md +++ b/src/documents/docs/conformance.md @@ -7899,7 +7899,9 @@ ECMA-376 Part 1 sections 17.16.4.3 and 17.16.4.3.1 define the `\\*` general formatting switch. PAGE, NUMPAGES and SECTIONPAGES now accept Arabic, ArabicDash, ROMAN, roman, ALPHABETIC and alphabetic, preserving the original instruction and cached-result XML. Case-sensitive Roman/alphabetic forms -select the corresponding upper/lower output. General formats override section +select the corresponding upper/lower output. Arabic and ArabicDash, which have +no case to select, are read in any letter case (LibreOffice writes `PAGE \\* +ARABIC`). General formats override section numbering; ArabicDash adds the specified spaced hyphens. The existing locked field behavior is retained. diff --git a/src/documents/src/engine/docx-page-fields.js b/src/documents/src/engine/docx-page-fields.js index c572ae9d6..48f8068e9 100644 --- a/src/documents/src/engine/docx-page-fields.js +++ b/src/documents/src/engine/docx-page-fields.js @@ -5,6 +5,9 @@ import { numericFieldFormat, formatNumericField } from './numeric-field-format.j const GENERAL_NUMBER_FORMATS = new Map([['Arabic', 'decimal'], ['ArabicDash', 'numberInDash'], ['ROMAN', 'upperRoman'], ['roman', 'lowerRoman'], ['ALPHABETIC', 'upperLetter'], ['alphabetic', 'lowerLetter']]); +// Arabic has no letter case to choose, so any spelling of it is read (LibreOffice writes PAGE \* ARABIC). The case +// of roman and alphabetic chooses the result's case, so only those two exact spellings are read. +const generalNumberFormat = name => GENERAL_NUMBER_FORMATS.get(/^arabic(dash)?$/i.test(name || '') ? (/dash$/i.test(name) ? 'ArabicDash' : 'Arabic') : name); const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; const fail = message => { throw new UnsupportedDocumentError(message); }; @@ -64,7 +67,7 @@ export function pageFieldInstruction(instruction) { for (let i = 0; i < tokens.length; i += 2) { if (tokens[i] === '\\#' && numericFormat === undefined && tokens[i + 1] !== undefined) numericFormat = numericFieldFormat(tokens[i + 1]); else if (tokens[i] === '\\*' && tokens[i + 1]?.toUpperCase() === 'MERGEFORMAT' && !mergeFormat) mergeFormat = true; - else if (tokens[i] === '\\*' && GENERAL_NUMBER_FORMATS.has(tokens[i + 1])) generalFormat = GENERAL_NUMBER_FORMATS.get(tokens[i + 1]); + else if (tokens[i] === '\\*' && generalNumberFormat(tokens[i + 1])) generalFormat = generalNumberFormat(tokens[i + 1]); else fail('an unsupported page field switch'); } return { type, mergeFormat, instruction, ...(numericFormat === undefined ? {} : { numericFormat }), ...(generalFormat === undefined ? {} : { generalFormat }) }; diff --git a/src/documents/tests/page-fields.test.js b/src/documents/tests/page-fields.test.js index 04b6c4631..d331f7a79 100644 --- a/src/documents/tests/page-fields.test.js +++ b/src/documents/tests/page-fields.test.js @@ -70,3 +70,14 @@ it('retains a locked general field cache and handles zero without discarding its expect(pageFieldValue({...f,locked:true},{pageNumber:12})).toBeNull(); expect(pageFieldValue({...f,locked:true},{pageNumber:12,pageStory:true})).toBe('xii'); }); +it('reads the Arabic switch in any letter case, as LibreOffice writes it (PAGE \\* ARABIC)',()=>{ + const context={pageNumber:27,pageCount:5,sectionPageCount:1,pageNumbering:{format:'upperRoman'}}; + for(const format of ['ARABIC','arabic','ARABICDASH','arabicdash']){ + const field=pageFieldInstruction(` PAGE \\* ${format} `); + expect(field.instruction).toBe(` PAGE \\* ${format} `); + expect(pageFieldValue(field,context)).toBe(/dash/i.test(format)?'- 27 -':'27'); + } + expect(pageFieldValue(pageFieldInstruction('NUMPAGES \\* ARABIC'),context)).toBe('5'); + // The case of roman and alphabetic chooses the case of the result, so only the two exact spellings are read. + expect(()=>pageFieldInstruction('PAGE \\* Roman')).toThrow(); +}); From 92589eee6db8e0c596a244ea8e466c4b185336a6 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:37:36 -0700 Subject: [PATCH 096/186] Documents: Stay in the header, footer or note after pressing F9 there Update Fields works on the body, so it closed the header, footer or note being edited and left the caret at the start of the body: the next keystrokes went into the body text. It now returns to the story with the caret where it was. --- src/documents/src/views/references.js | 9 +++++- .../tests/browser/field-update-story.spec.js | 31 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 src/documents/tests/browser/field-update-story.spec.js diff --git a/src/documents/src/views/references.js b/src/documents/src/views/references.js index 225a8d661..127a612b1 100644 --- a/src/documents/src/views/references.js +++ b/src/documents/src/views/references.js @@ -184,11 +184,18 @@ export function createReferences({ editor, pkg, error, base = {}, onChange = () } /** Update every field this editor can evaluate: sequences, references, citations, bibliographies and page numbers. */ async function updateAllFields() { - if (!ready()) return; + const engine = ready(); if (!engine) return; + engine.finishComposition(); + // Pressed in a header, footer or note, F9 updates the body's fields and then returns there: it used to + // leave the caret at the start of the body, where the next keystrokes went. + const story = engine.session.path, page = engine.storyPage, { anchor, focus } = engine.model.selection; const { updateSequenceFields } = await import('../engine/field-update.js'); const issues = [], notesMaps = await noteNumberMaps(); await runBody(body => { updateSequenceFields(body, { onIssue: issue => issues.push(issue), notes: notesMaps }); return true; }); await citations.refreshFields(); + if (story !== null && engine.session.path === null && engine.editStory(story, page) && engine.session.path === story) { + engine.model.select(anchor, focus); engine.revealCaret?.(); onChange(); + } if (issues.length) error(new Error([...new Set(issues.map(issue => issue.message))].join(' '))); } diff --git a/src/documents/tests/browser/field-update-story.spec.js b/src/documents/tests/browser/field-update-story.spec.js new file mode 100644 index 000000000..36f367a94 --- /dev/null +++ b/src/documents/tests/browser/field-update-story.spec.js @@ -0,0 +1,31 @@ +import { test, expect } from '@playwright/test'; + +// F9 (Update Fields) pressed in a footer updates the document's fields and leaves the caret in the footer. +test('F9 in a footer keeps the footer open with the caret where it was', async ({ page }) => { + await page.goto('/mock.html'); + const input = page.getByRole('textbox', { name: 'Document text' }); + await expect(input).toBeVisible(); + await page.getByLabel('Open DOCX file').setInputFiles('public/fixtures/section-page-numbers.docx'); + await expect(input).toHaveValue(/^Roman start, page 1/); + await expect(input).toHaveJSProperty('readOnly', false); + const state = () => page.evaluate(() => { + const e = window.__docs.current.view.engine; + return { path: e.session.path, range: [...e.model.range], body: e.session.body.text, + story: e.session.path && e.pageStories.stories[e.session.path].paragraphs.map(p => p.runs.map(r => r.text).join('')).join('\n') }; + }); + const footer = await page.evaluate(() => { + const e = window.__docs.current.view.engine; + const path = e.storyLayout.lines.find(line => line.page === 0 && e.pageStories.stories[line.pageStory]?.kind === 'footer').pageStory; + e.editStory(path, 0); e.model.select(2); e.focus(); + return path; + }); + const before = await state(); + expect(before).toMatchObject({ path: footer, range: [2, 2] }); + await page.keyboard.press('F9'); + await expect.poll(async () => (await state()).path).toBe(footer); + expect((await state()).range).toEqual([2, 2]); + await page.keyboard.insertText('X'); + const after = await state(); + expect(after.body).toBe(before.body); + expect(after.story).toBe(`${before.story.slice(0, 2)}X${before.story.slice(2)}`); +}); From 01acc9a4b79c5da43e99dd09e0bbda2953e9c8fd Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:42:57 -0700 Subject: [PATCH 097/186] Documents: Leave hidden bookmarks out of the link dialog's bookmark list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The link dialog listed every bookmark, including the hidden _Toc, _Ref and _GoBack ones that tables of contents and cross-references create, so a document with a table of contents offered dozens of _Toc… names beside the user's own. They are now left out, as in the Bookmarks dialog and Go To; a link that already points at one keeps it. --- src/documents/src/views/hyperlink.js | 4 +++- src/documents/tests/browser/bookmarks.spec.js | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/documents/src/views/hyperlink.js b/src/documents/src/views/hyperlink.js index c1c40049f..f578c048f 100644 --- a/src/documents/src/views/hyperlink.js +++ b/src/documents/src/views/hyperlink.js @@ -6,7 +6,9 @@ import { hrefForToken } from '../lib/linkify.js'; export function openHyperlink({ link, bookmarks = [], insert, apply, remove, open, anchor, focusFirst = true }) { const kind = el('select.input', { 'aria-label': 'Link destination type' }, el('option', { value: 'address' }, 'Address'), el('option', { value: 'bookmark' }, 'Bookmark')); kind.value = internalHyperlink(link) ? 'bookmark' : 'address'; - const choices = [...new Set(bookmarks.map(b => b.name))]; + // Hidden bookmarks (_Toc…, _Ref…, _GoBack: those tables of contents and cross-references make) are left out, as in + // the Bookmarks dialog; a link that already goes to one keeps it. + const choices = [...new Set(bookmarks.map(b => b.name).filter(name => !name?.startsWith('_')))]; if (internalHyperlink(link) && link.anchor !== null && !choices.includes(link.anchor)) choices.push(link.anchor); const bookmark = el('select.input', { 'aria-label': 'Link bookmark' }, el('option', { value: 'null' }, 'Start of document'), ...choices.map(name => el('option', { value: JSON.stringify(name) }, name || '(unnamed)'))); bookmark.value = internalHyperlink(link) ? JSON.stringify(link.anchor) : 'null'; diff --git a/src/documents/tests/browser/bookmarks.spec.js b/src/documents/tests/browser/bookmarks.spec.js index 098646f47..24a6da307 100644 --- a/src/documents/tests/browser/bookmarks.spec.js +++ b/src/documents/tests/browser/bookmarks.spec.js @@ -64,3 +64,19 @@ test('refuses a bookmark name starting with an underscore, which would be hidden await expect(page.getByRole('dialog').getByRole('alert')).toContainText('Start the name with a letter'); await expect(page.getByRole('listbox',{name:'Document bookmarks',exact:true}).locator('option')).toHaveText([/^Draft_1 ·/]); }); + +test('the link dialog offers the document\'s bookmarks without the hidden ones a table of contents adds',async({page})=>{ + await page.goto('/mock.html#/new'); + const input=page.getByRole('textbox',{name:'Document text'});await expect(input).toBeVisible();await input.fill('Chapter\nSee here'); + await page.evaluate(async()=>{ + const e=window.__docs.current.view.engine;const m=e.model; + const toc=await import('/src/engine/toc.js'); + e.execute(()=>{m.select(0);m.setParagraphStyle(m.paragraphStyles.find(s=>s.name==='Heading 1').id);m.select(0);toc.insertTableOfContents(m,{});return true;}); + }); + await choose(input,'Chapter',true);await manager(page);await page.getByRole('textbox',{name:'Bookmark name',exact:true}).fill('Visible');await page.getByRole('dialog').getByRole('button',{name:'Add bookmark',exact:true}).click();await close(page); + expect(await page.evaluate(()=>window.__docs.current.view.engine.bookmarks.map(b=>b.name).some(n=>n.startsWith('_Toc')))).toBe(true); + await choose(input,'See here');await linkDialog(page,input); + await page.getByRole('combobox',{name:'Link destination type',exact:true}).selectOption('bookmark'); + await expect(page.getByRole('combobox',{name:'Link bookmark',exact:true}).locator('option')).toHaveText(['Start of document','Visible']); + await close(page,'Cancel'); +}); From 1c67f14fffade08ed9e22c5e87b7aa39d74a8bea Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:47:28 -0700 Subject: [PATCH 098/186] Documents: Show the page's own number when inserting a page number field in the body A PAGE field inserted in the body (Insert > Field, or Page Number > Current Position) got the sheet's position as its result: its format switch was ignored (PAGE \* ROMAN showed 1) and so were the section's start and number format (a section numbered ii, iii showed 1). The result is now the page's number in the section's format, which a number switch overrides; Update Fields still leaves body page numbers as they are. --- src/documents/src/engine/field-results.js | 16 ++++++- src/documents/src/views/insert-tab.js | 14 ++++-- .../browser/body-page-number-insert.spec.js | 45 +++++++++++++++++++ src/documents/tests/field-results.test.js | 19 ++++++++ 4 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 src/documents/tests/browser/body-page-number-insert.spec.js diff --git a/src/documents/src/engine/field-results.js b/src/documents/src/engine/field-results.js index dc202d920..4e3d4c837 100644 --- a/src/documents/src/engine/field-results.js +++ b/src/documents/src/engine/field-results.js @@ -170,11 +170,22 @@ export function evaluateField(instruction, context = {}) { value = properties[key] || ''; break; } case 'NUMPAGES': if (!Number.isInteger(context.pages)) return null; value = String(context.pages); break; + case 'PAGE': { + // `page` is the page's number and `pageFormat` its section's number format. A number format switch + // (\* roman) formats the number itself, overriding the section's format (§17.16.4.3). + if (!Number.isInteger(context.page) || context.page < 0) return null; + const switched = formats.some(f => /^(arabic|arabicdash|roman|alphabetic)$/i.test(f)); + try { value = switched ? String(context.page) : formatPageNumber(context.page, context.pageFormat || 'decimal'); } catch { value = String(context.page); } + break; + } case 'NUMWORDS': if (context.text === undefined) return null; value = String(words(context.text)); break; case 'NUMCHARS': if (context.text === undefined) return null; value = String([...String(context.text).replace(/[\s\ufffc]/gu, '')].length); break; default: return null; } - for (const format of formats) if (!/^mergeformat$/i.test(format) && !/^charformat$/i.test(format)) value = applyGeneralFormat(value, format); + for (const format of formats) { + if (/^mergeformat$/i.test(format) || /^charformat$/i.test(format)) continue; + value = name === 'PAGE' && /^arabic$/i.test(format) ? String(context.page) : name === 'PAGE' && /^arabicdash$/i.test(format) ? `- ${context.page} -` : applyGeneralFormat(value, format); + } return value; } @@ -218,7 +229,8 @@ export function fieldResultPlan(model, context = fieldEvaluationContext()) { const changes = [], text = model.text, ranges = bodyFieldRanges(model.paragraphs); for (const range of ranges) { if (range.parentId || ranges.some(r => r.parentId === range.field.id)) continue; - if (range.field.locked || range.field.type === 'SEQ' || range.field.type === 'REF') continue; + // A body PAGE field keeps the number it was given: the context's page is the caret's, not the field's. + if (range.field.locked || ['SEQ', 'REF', 'PAGE'].includes(range.field.type)) continue; if (!context && !['DATE', 'TIME'].includes(range.field.type)) continue; const value = evaluateField(range.field.instruction, { ...(context || {}), text }); if (value !== null && text.slice(range.start, range.end) !== value) changes.push({ range, text: value }); diff --git a/src/documents/src/views/insert-tab.js b/src/documents/src/views/insert-tab.js index 37a740220..413f30831 100644 --- a/src/documents/src/views/insert-tab.js +++ b/src/documents/src/views/insert-tab.js @@ -24,6 +24,7 @@ import { pictureFragment, fittedSize } from '../engine/picture-insert.js'; import { imageInfo, MAX_IMAGE_SIDE, MAX_IMAGE_PIXELS } from '../engine/images.js'; import { diagramDrawingXml, parseDiagramDescription, DIAGRAM_LAYOUTS } from '../engine/diagram-builder.js'; import { insertBodyField, fieldInstruction, evaluateField, packageDocumentProperties, setFieldContextProvider, DOCUMENT_PROPERTY_NAMES, formatDatePicture } from '../engine/field-results.js'; +import { pageNumberSequence } from '../engine/page-numbering.js'; import { linearToOmml, ommlToLinear, ommlToEditableLinear, EQUATION_GALLERY } from '../engine/math-linear.js'; import { ommlToMathml } from '../engine/omml-mathml.js'; import { dropCapState, setDropCap } from '../engine/drop-caps.js'; @@ -87,10 +88,17 @@ export function createInsert({ editor, pkg, error, base = {}, docName = () => '' const today = () => new Intl.DateTimeFormat(undefined, { dateStyle: 'long' }).format(new Date()); /** Run an editor transaction, reporting failures; returns whether it applied. */ const run = (engine, action) => { let failure = null; const ok = engine.execute(action, e => { failure = e; }); if (failure) fail(failure); engine.focus(); if (ok) onChange(); return ok; }; + /** The number the caret's page shows ({ page, pageFormat }): its section's start and format apply, as in headers and footers. */ + const caretPageNumber = engine => { + const index = engine?.caretPageIndex ?? 0, layout = engine?.layout; + if (!layout) return { page: index + 1 }; + const numbers = pageNumberSequence(layout.pageSections || [], engine.pageStories, layout.numberingApproximations); + return { page: numbers[index] ?? index + 1, pageFormat: engine.pageStories?.sections?.[engine.sectionOfPage(index)]?.pageNumbering?.format }; + }; const fieldContext = () => { const engine = editor(); return { now: new Date(), properties: packageDocumentProperties(pkg()), fileName: /\.\w+$/.test(docName() || '') ? docName() : `${docName() || 'Document'}.docx`, user: { name: user() || '' }, - pages: engine?.layout?.pageCount, page: (engine?.caretPageIndex ?? 0) + 1 }; + pages: engine?.layout?.pageCount, ...caretPageNumber(engine) }; }; setFieldContextProvider(fieldContext); @@ -373,7 +381,7 @@ export function createInsert({ editor, pkg, error, base = {}, docName = () => '' return; } // In the body, PAGE and NUMPAGES are body fields showing their current values. - const page = String((engine.caretPageIndex ?? 0) + 1), pages = String(engine.layout.pageCount); + const context = fieldContext(), page = evaluateField(fieldInstruction({ name: 'PAGE', preserve: true }), context) ?? String(context.page), pages = String(engine.layout.pageCount); run(engine, () => { for (const part of design.parts) { if (typeof part === 'object' && part.field) insertBodyField(engine.model, fieldInstruction({ name: part.field, preserve: true }), part.field === 'PAGE' ? page : pages); @@ -522,7 +530,7 @@ export function createInsert({ editor, pkg, error, base = {}, docName = () => '' openFieldDialog({ context: { ...fieldContext(), text: engine.model.text }, bookmarks: (engine.bookmarks || []).map(b => b.name).filter(n => !n.startsWith('_')), onClose: () => queueMicrotask(() => editor()?.focus()), onInsert: ({ name, instruction }) => { const engine = ready(); if (!engine) return; - const cached = name === 'PAGE' ? String((engine.caretPageIndex ?? 0) + 1) : name === 'SEQ' ? '1' : name === 'REF' ? (() => { const target = /REF\s+"?([^"\s]+)/.exec(instruction)?.[1], mark = engine.bookmarks.find(b => b.name === target); return mark ? engine.model.text.slice(mark.start, mark.end) : ''; })() : null; + const cached = name === 'SEQ' ? '1' : name === 'REF' ? (() => { const target = /REF\s+"?([^"\s]+)/.exec(instruction)?.[1], mark = engine.bookmarks.find(b => b.name === target); return mark ? engine.model.text.slice(mark.start, mark.end) : ''; })() : null; insertFieldInstruction(instruction, cached); // A new sequence or reference number settles when fields update, as captions do. if (name === 'SEQ' || name === 'REF') base.updateFields?.(); diff --git a/src/documents/tests/browser/body-page-number-insert.spec.js b/src/documents/tests/browser/body-page-number-insert.spec.js new file mode 100644 index 000000000..d8c0c38cf --- /dev/null +++ b/src/documents/tests/browser/body-page-number-insert.spec.js @@ -0,0 +1,45 @@ +import { test, expect } from '@playwright/test'; +import { onTab } from './helpers/editor-tools.js'; + +// A PAGE field inserted in the body shows the page's number as the page shows it: its format switch +// (\* roman) and the section's numbering (start and format) apply. +async function open(page) { + await page.goto('/mock.html'); + const input = page.getByRole('textbox', { name: 'Document text' }); + await expect(input).toBeVisible(); + // Its first section is numbered ii, iii, … (lowerRoman from 2). + await page.getByLabel('Open DOCX file').setInputFiles('public/fixtures/section-page-numbers-offset.docx'); + await expect(input).toHaveValue(/^Roman start, page 1/); + await expect(input).toHaveJSProperty('readOnly', false); + await page.evaluate(() => { const e = window.__docs.current.view.engine; e.model.select(e.model.paragraphs[0].runs.reduce((n, r) => n + r.text.length, 0)); e.focus(); }); + return input; +} +const firstParagraph = page => page.evaluate(() => window.__docs.current.view.engine.session.body.text.split('\n')[0]); + +test('the Field dialog inserts PAGE with its format switch applied', async ({ page }) => { + await open(page); + const before = await firstParagraph(page); + await onTab(page, 'insert'); + await page.locator('#ribbon-panel-insert').getByRole('button', { name: 'Quick Parts', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Field…' }).click(); + const field = page.getByRole('dialog', { name: 'Field' }); + await field.getByRole('listbox', { name: 'Field names' }).selectOption('PAGE'); + await field.getByRole('combobox', { name: 'Format' }).selectOption('ROMAN'); + await expect(field.locator('.insert-field-result')).toHaveText('II'); + await field.getByRole('button', { name: 'Insert' }).click(); + await expect.poll(() => firstParagraph(page)).toBe(`${before}II`); +}); + +test('Page Number > Current Position in the body shows the section\'s numbering', async ({ page }) => { + await open(page); + const before = await firstParagraph(page); + await onTab(page, 'insert'); + await page.locator('#ribbon-panel-insert').getByRole('button', { name: 'Page Number', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Current Position', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Plain Number', exact: true }).click(); + await expect.poll(() => firstParagraph(page)).toBe(`${before}ii`); + // Update Fields leaves body page numbers as they are (their result is the page they were inserted on). + await page.keyboard.press('F9'); + await page.waitForTimeout(300); + expect(await firstParagraph(page)).toBe(`${before}ii`); +}); diff --git a/src/documents/tests/field-results.test.js b/src/documents/tests/field-results.test.js index 343954d47..e2725b40e 100644 --- a/src/documents/tests/field-results.test.js +++ b/src/documents/tests/field-results.test.js @@ -161,3 +161,22 @@ it('inserts DATE and AUTHOR fields that save as fields, reopen editable and upda expect(again.text).toBe('Signed on October 3, 2026 by Ada Byron.'); setFieldContextProvider(null); }); + +it('gives a PAGE field the page\'s number in its section\'s format, which a number switch overrides', () => { + expect(evaluateField(' PAGE ', {})).toBeNull(); + expect(evaluateField(fieldInstruction({ name: 'PAGE' }), { page: 2 })).toBe('2'); + expect(evaluateField(fieldInstruction({ name: 'PAGE' }), { page: 2, pageFormat: 'lowerRoman' })).toBe('ii'); + expect(evaluateField(fieldInstruction({ name: 'PAGE', format: 'ROMAN' }), { page: 4 })).toBe('IV'); + expect(evaluateField(fieldInstruction({ name: 'PAGE', format: 'Arabic' }), { page: 4, pageFormat: 'upperRoman' })).toBe('4'); + expect(evaluateField(fieldInstruction({ name: 'PAGE', format: 'alphabetic' }), { page: 28, pageFormat: 'upperRoman' })).toBe('bb'); + expect(evaluateField(' PAGE \\* roman \\* Upper ', { page: 3 })).toBe('III'); +}); + +it('leaves a body PAGE field\'s number alone when fields update', () => { + const { doc } = blank(); + doc.replace('On page '); + insertBodyField(doc, fieldInstruction({ name: 'PAGE' }), '7'); + setFieldContextProvider(() => ({ now: when, page: 1, pages: 9 })); + try { updateSequenceFields(doc); } finally { setFieldContextProvider(null); } + expect(doc.text).toBe('On page 7'); +}); From 14ad466eeca5e9909b742750e56a021ccc66b2eb Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:49:31 -0700 Subject: [PATCH 099/186] Documents: List every marked citation in a table of authorities, whatever order it was marked in A citation marked after the authority's long form gets only the short form (TA \s). When that later mark sat earlier in the document than the long form, it was dropped from the table, because short forms only joined an authority already seen in document order. Short forms now join their authority wherever its long form is marked. --- src/documents/src/engine/authorities.js | 12 ++++++++---- src/documents/tests/index-authorities.test.js | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/documents/src/engine/authorities.js b/src/documents/src/engine/authorities.js index 03f024021..c558615ae 100644 --- a/src/documents/src/engine/authorities.js +++ b/src/documents/src/engine/authorities.js @@ -79,10 +79,14 @@ export function authorities(paragraphs) { const marks = authorityMarks(paragraphs), list = []; for (const mark of marks) { const a = mark.authority; - let owner = a.long ? list.find(x => x.long === a.long) : list.find(x => x.short && x.short === a.short); - if (!owner && a.long) list.push(owner = { long: a.long, short: a.short || a.long, category: a.category, marks: [] }); - if (!owner) continue; // A short form without its long form is not listed. - owner.marks.push(mark); + if (a.long && !list.some(x => x.long === a.long)) list.push({ long: a.long, short: a.short || a.long, category: a.category, marks: [] }); + } + // A short-form mark belongs to the authority with that short form wherever its long form is marked, before or + // after it: marking an earlier citation after a later one dropped the earlier page from the table. + for (const mark of marks) { + const a = mark.authority; + const owner = a.long ? list.find(x => x.long === a.long) : list.find(x => x.short && x.short === a.short); + if (owner) owner.marks.push(mark); // A short form without its long form is not listed. } return list; } diff --git a/src/documents/tests/index-authorities.test.js b/src/documents/tests/index-authorities.test.js index c096b9b74..179d6e8dd 100644 --- a/src/documents/tests/index-authorities.test.js +++ b/src/documents/tests/index-authorities.test.js @@ -137,4 +137,18 @@ describe('tables of authorities', () => { writeTableOfAuthorities(again, { existing: tableFields(again.paragraphs)[0], options: { category: 1, passim: false, headings: true, keepFormatting: true, entrySeparator: '\t', pageSeparator: ', ', extra: [] }, pageOf: pages(pkg, again) }); expect(again.text).toContain('Roe v. Wade, 410 U.S. 113 (1973)\t2, 3, 4, 5, 6, 7'); }); + + it('lists a short-form mark that comes before its authority\'s long-form mark', async () => { + // The long form is marked on page 3 first; an earlier citation on page 1 is marked afterwards and gets the short form only. + const { pkg, d } = await setup('Roe v. Wade is discussed.\fNothing here.\fRoe v. Wade, 410 U.S. 113 (1973), held so.\n'); + const long = 'Roe v. Wade, 410 U.S. 113 (1973)'; + let at = d.text.lastIndexOf(long); d.select(at, at + long.length); + markAuthority(d, { long, short: 'Roe v. Wade', category: 1 }); + at = d.text.indexOf('Roe v. Wade'); d.select(at, at + 11); + markAuthority(d, { long, short: 'Roe v. Wade', category: 1 }); + expect(authorities(d.paragraphs).map(a => [a.long, a.marks.length])).toEqual([[long, 2]]); + d.select(d.text.length); + writeTableOfAuthorities(d, { options: { category: 1, passim: false, headings: false, keepFormatting: true, entrySeparator: '\t', pageSeparator: ', ', extra: [] }, pageOf: pages(pkg, d) }); + expect(d.text.split('\n').at(-2)).toBe(`${long}\t1, 3`); + }); }); From c9104b01525193ec7741c44ad5ba11be09aeb8a4 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:52:05 -0700 Subject: [PATCH 100/186] Documents: Refresh cross-references to text with a footnote or picture without an error A cross-reference to a caption or heading that holds a footnote reference, picture or equation is inserted without those objects. Update Fields then refused to refresh it ("A REF with structural or object content cannot be refreshed yet") and showed that error on every F9, and the result no longer followed edits to its target. The refreshed result now leaves the objects out the way the inserted one does; a target spanning paragraphs is still kept as it is. --- src/documents/src/engine/field-update.js | 5 ++++- src/documents/tests/captions.test.js | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/documents/src/engine/field-update.js b/src/documents/src/engine/field-update.js index 069d34e13..bf68bdf0d 100644 --- a/src/documents/src/engine/field-update.js +++ b/src/documents/src/engine/field-update.js @@ -104,7 +104,10 @@ export function fieldUpdatePlan(model, { onIssue = () => {}, notes = null } = {} } else if (relative) value = withCases(range.end <= bookmark.start ? 'below' : 'above', cases); else { value = text.slice(bookmark.start, Math.min(bookmark.end, text.length)); - if (bookmark.columnRange || /[\n]/.test(value)) { retain('A REF with structural or object content cannot be refreshed yet'); continue; } + if (bookmark.columnRange || value.includes('\n')) { retain('A REF with structural content cannot be refreshed yet'); continue; } + // Notes, pictures and equations in the target are object characters: the result leaves them out and + // shows breaks as spaces, as the cross-reference was inserted (captions.js). + value = value.replace(/\uFFFC/g, '').replace(/[\f\u000e]/g, ' '); value = withCases(value, cases); } } else if (type === '=') value = formulaResult(model, range.field.instruction, range.cellId || null); diff --git a/src/documents/tests/captions.test.js b/src/documents/tests/captions.test.js index 8a822919a..2fc37ff3d 100644 --- a/src/documents/tests/captions.test.js +++ b/src/documents/tests/captions.test.js @@ -62,6 +62,16 @@ describe('captions and cross-references', () => { expect(target.captionText).toBe('Second chart'); for (const show of ['entire', 'caption', 'label']) { d.select(d.text.length); d.replace(` ${show}: `); insertCrossReference(d, target, { show }); } expect(d.text.split('\n').at(-1)).toBe('Conclusion entire: Figure 1: Second chart caption: Second chart label: Figure 1'); + // Updating fields keeps the results as inserted, and refreshes them, without reporting them as unrefreshable. + const issues = []; + expect(updateSequenceFields(d, { onIssue: issue => issues.push(issue.message) })).toBe(0); + expect(issues).toEqual([]); + d.select(d.text.indexOf('Second\uFFFC') + 7); d.replace(' bar'); + updateSequenceFields(d, { onIssue: issue => issues.push(issue.message) }); + expect(issues).toEqual([]); + expect(d.text.split('\n').at(-1)).toBe('Conclusion entire: Figure 1: Second bar chart caption: Second bar chart label: Figure 1'); + const at = d.text.indexOf('Second\uFFFC') + 7; d.select(at, at + 4); d.replace(''); + updateSequenceFields(d); const bytes = new Uint8Array(await pkg.export(d.paragraphs, d.pageSettings, d.numbering, d.styles, d.tables, d.assets, { [path]: { paragraphs: note.paragraphs, tables: [] } }).arrayBuffer()); expect(strFromU8(unzipSync(bytes)['word/document.xml'])).not.toContain('\uFFFC'); const reopened = new TextDocument((await DocxPackage.open(bytes)).paragraphs); From 075aaea3444fd54ad37947075c8b0e82b11baab3 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 21:54:49 -0700 Subject: [PATCH 101/186] Documents: Move on to the next footnote when Next Footnote is pressed inside a note From inside a footnote or endnote, Next Footnote went back to that same note's reference in the text instead of the following one (Previous worked). It now steps from just after the note's reference, as it does from the caret in the text. --- src/documents/src/views/references-notes.js | 3 +- .../tests/browser/note-navigation.spec.js | 40 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/documents/tests/browser/note-navigation.spec.js diff --git a/src/documents/src/views/references-notes.js b/src/documents/src/views/references-notes.js index 551dc153f..ef93456da 100644 --- a/src/documents/src/views/references-notes.js +++ b/src/documents/src/views/references-notes.js @@ -68,8 +68,9 @@ export function createNoteCommands(ctx) { let at; if (engine.session.path !== null) { const story = engine.pageStories?.stories[engine.session.path]; + // From a note, step from its reference as from the caret just after it: next is the following note, not this one. const own = refs.find(r => r.id === story?.noteId && story?.kind === kind); - at = own ? own.position : 0; + at = own ? own.position + 1 : 0; engine.editStory(null); } else at = engine.model.range[direction > 0 ? 1 : 0]; // The caret rests just after a reference mark; next looks beyond it, previous before it. Both wrap. diff --git a/src/documents/tests/browser/note-navigation.spec.js b/src/documents/tests/browser/note-navigation.spec.js new file mode 100644 index 000000000..90df38d0b --- /dev/null +++ b/src/documents/tests/browser/note-navigation.spec.js @@ -0,0 +1,40 @@ +import { test, expect } from '@playwright/test'; +import { onTab } from './helpers/editor-tools.js'; + +const engineState = page => page.evaluate(() => { const e = window.__docs.current.view.engine; return { path: e.session.path, at: e.model.range[0], text: e.session.body.text }; }); + +// From inside a footnote, Next Footnote goes on to the following note's reference (and Previous to the one before), +// as it does from that note's reference in the text. +test('Next and Previous Footnote move on from the note being edited', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/mock.html#/new'); + await expect((await onTab(page, 'home')).getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + await page.locator('canvas.canvas-page').first().click({ position: { x: 150, y: 110 } }); + await page.keyboard.type('One. Two. Three.'); + await onTab(page, 'references'); + const ribbon = page.locator('#ribbon-panel-references'); + for (const [i, word] of ['One.', 'Two.', 'Three.'].entries()) { + await page.evaluate(word => { const e = window.__docs.current.view.engine; e.editStory(null); const m = e.model; m.select(m.text.indexOf(word) + word.length); e.focus(); }, word); + await ribbon.getByRole('button', { name: 'Insert Footnote', exact: true }).click(); + await expect(page.getByRole('textbox', { name: 'Footnote text' })).toBeFocused(); + await page.keyboard.type(`Note ${i + 1}`); + } + const text = (await engineState(page)).text, refs = [...text].flatMap((ch, i) => ch === '' ? [i] : []); + expect(refs).toHaveLength(3); + // Edit the second note, then go to the next footnote. + await page.evaluate(() => { + const e = window.__docs.current.view.engine, path = Object.keys(e.pageStories.stories).find(p => e.pageStories.stories[p].noteId === '2'); + e.editStory(path, 0); e.model.select(e.model.text.length); e.focus(); + }); + await expect.poll(async () => (await engineState(page)).path).toMatch(/#2$/); + const go = async name => { await ribbon.getByRole('button', { name: 'Next Footnote', exact: true }).click(); await page.getByRole('menuitem', { name, exact: true }).click(); }; + await go('Next Footnote'); + await expect.poll(() => engineState(page)).toMatchObject({ path: null, at: refs[2] + 1 }); + // And from the second note again, Previous Footnote goes to the first. + await page.evaluate(() => { + const e = window.__docs.current.view.engine, path = Object.keys(e.pageStories.stories).find(p => e.pageStories.stories[p].noteId === '2'); + e.editStory(path, 0); e.model.select(0); e.focus(); + }); + await go('Previous Footnote'); + await expect.poll(() => engineState(page)).toMatchObject({ path: null, at: refs[0] + 1 }); +}); From 1957e0fb67b4a26ad7ba0abe680847ea8b900510 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:01:12 -0700 Subject: [PATCH 102/186] Documents: Say why Update page numbers only cannot update a table made in LibreOffice LibreOffice writes a table of contents whose page numbers are plain text, not PAGEREF fields. Update Table > Update page numbers only found nothing to refresh and closed as if it had worked, leaving the old numbers. The dialog now stays open and explains that the table has no page-number fields, so Update entire table can be chosen instead. --- src/documents/src/views/references-toc.js | 12 +++++-- .../browser/toc-without-pagerefs.spec.js | 33 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 src/documents/tests/browser/toc-without-pagerefs.spec.js diff --git a/src/documents/src/views/references-toc.js b/src/documents/src/views/references-toc.js index dcbe15213..d771e9074 100644 --- a/src/documents/src/views/references-toc.js +++ b/src/documents/src/views/references-toc.js @@ -128,11 +128,19 @@ export function createTocCommands(ctx) { async function updateTable(figures = false, mode = 'entire') { if (mode === 'pages') { - return runBody((body, toc) => { + // A table another application wrote can show its page numbers as plain text (LibreOffice does): with no + // PAGEREF fields there is nothing to refresh, and the update did nothing without saying so. + let plain = false; + const result = await runBody((body, toc) => { const target = tableFor(toc, body, figures); if (!target) return false; - return { only: toc.tocPageReferences(body, target) }; + const only = toc.tocPageReferences(body, target); + if (!only.size && !target.options.omitPageNumbers) { plain = true; return false; } + return { only }; }, { modules: ['toc'], scopedRefresh: true }); + // Shown in the Update dialog, which stays open for the other choice. + if (plain) throw new Error(`This table of ${figures ? 'figures' : 'contents'} has no page-number fields to update. Choose Update entire table to rebuild it with its page numbers.`); + return result; } return runBody((body, toc) => { const target = tableFor(toc, body, figures); diff --git a/src/documents/tests/browser/toc-without-pagerefs.spec.js b/src/documents/tests/browser/toc-without-pagerefs.spec.js new file mode 100644 index 000000000..d9ff620b4 --- /dev/null +++ b/src/documents/tests/browser/toc-without-pagerefs.spec.js @@ -0,0 +1,33 @@ +import { test, expect } from '@playwright/test'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { unzipSync, zipSync, strToU8 } from 'fflate'; + +// LibreOffice writes a table of contents whose page numbers are plain text, not PAGEREF fields +// (TOC \f \o "1-9" \h in a Table of Contents docPartObj). "Update page numbers only" has nothing to refresh there. +const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"'; +const heading = (text, id) => `${text}`; +const entry = (text, id, first, last) => `${first ? ' TOC \\f \\o "1-9" \\h' : ''}${text}1${last ? '' : ''}`; + +test('Update page numbers only explains why a table without page-number fields was not updated', async ({ page }, info) => { + const parts = unzipSync(readFileSync('public/fixtures/toc-pageref.docx')); + parts['word/document.xml'] = strToU8(`Table of Contents${entry('Alpha', 1, true, false)}${entry('Beta', 2, false, true)}${heading('Alpha', 1)}Text${heading('Beta', 2)}More`); + const path = info.outputPath('plain-page-toc.docx'); writeFileSync(path, zipSync(parts)); + await page.goto('/mock.html'); + const input = page.getByRole('textbox', { name: 'Document text' }); + await expect(input).toBeVisible(); + await page.getByLabel('Open DOCX file').setInputFiles(path); + await expect(input).toHaveValue(/^Table of Contents\nAlpha\t1\nBeta\t1\n/); + await expect(input).toHaveJSProperty('readOnly', false); + await page.locator('#ribbon-tab-references').click(); + const ribbon = page.locator('#ribbon-panel-references'); + await ribbon.getByRole('button', { name: 'Update Table', exact: true }).first().click(); + const dialog = page.getByRole('dialog', { name: 'Update Table of Contents' }); + await dialog.getByRole('button', { name: 'Update', exact: true }).click(); + // The dialog stays open and says why, so the other choice is one click away. + await expect(dialog.getByRole('alert')).toHaveText(/no page-number fields to update.*Update entire table/); + await expect(input).toHaveValue(/^Table of Contents\nAlpha\t1\nBeta\t1\n/); + await dialog.getByLabel('Update entire table (new entries and text)').check(); + await dialog.getByRole('button', { name: 'Update', exact: true }).click(); + await expect(dialog).toBeHidden(); + await expect(input).toHaveValue(/^Table of Contents\nAlpha\t1\nBeta\t2\n/); +}); From e6d06f7d2808031bb5513f4e30133a8fb74204e8 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:04:10 -0700 Subject: [PATCH 103/186] Documents: Follow a bookmark link from a footnote or header to the bookmark in the text Following a link to a bookmark (or a cross-reference's hyperlink) from inside a footnote, endnote, header or footer looked the bookmark up in that story, found none, and put the caret at the start of the note. Bookmarks live in the body, so going to one now leaves the story first; Go To and the Bookmarks dialog get the same. --- src/documents/src/engine/canvas-editor.js | 6 +++-- .../browser/story-internal-links.spec.js | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 src/documents/tests/browser/story-internal-links.spec.js diff --git a/src/documents/src/engine/canvas-editor.js b/src/documents/src/engine/canvas-editor.js index d18cc432e..a3de68132 100644 --- a/src/documents/src/engine/canvas-editor.js +++ b/src/documents/src/engine/canvas-editor.js @@ -1140,7 +1140,7 @@ export class CanvasEditor { } openHyperlink(link = this.model.hyperlink?.link) { if (!link) return; - if (internalHyperlink(link)) { this.goToBookmark(this.model.bookmarks.find(b => b.name === link.anchor)?.id); return; } + if (internalHyperlink(link)) { this.goToBookmark(this.session.body.bookmarks.find(b => b.name === link.anchor)?.id); return; } if (link.history && openableHyperlink(link)) this.visitedLinks.add(link.target); this.onOpenLink(link.target); } @@ -1176,7 +1176,9 @@ export class CanvasEditor { renameBookmark(id, name) { return this.execute(() => this.model.renameBookmark(id, name)); } removeBookmark(id) { return this.execute(() => this.model.removeBookmark(id)); } goToBookmark(id) { - this.finishComposition(); const bookmark = this.model.bookmarks.find(b => b.id === id); + // Bookmarks are in the body: a link followed from a note, header or footer goes there, not to the story's start. + this.finishComposition(); if (this.session.path !== null) this.editStory(null); + const bookmark = this.model.bookmarks.find(b => b.id === id); this.model.select(bookmark?.columnRange?.target ?? bookmark?.start ?? 0); this.caretAffinity = 'forward'; this.caretPage = null; this.preferredX = null; this.focus(); this.revealCaret(); this.onSelection(); return true; diff --git a/src/documents/tests/browser/story-internal-links.spec.js b/src/documents/tests/browser/story-internal-links.spec.js new file mode 100644 index 000000000..90b87e66b --- /dev/null +++ b/src/documents/tests/browser/story-internal-links.spec.js @@ -0,0 +1,24 @@ +import { test, expect } from '@playwright/test'; +import { onTab } from './helpers/editor-tools.js'; + +// A link to a bookmark followed from a footnote goes to that bookmark in the text (notes and headers hold no +// bookmarks of their own), not to the start of the note. +test('a bookmark link in a footnote leads to the bookmark in the text', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/mock.html#/new'); + await expect((await onTab(page, 'home')).getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + await page.locator('canvas.canvas-page').first().click({ position: { x: 150, y: 110 } }); + await page.keyboard.type('Claim here. Later the Target section.'); + await page.evaluate(() => { const e = window.__docs.current.view.engine, m = e.model, at = m.text.indexOf('Target'); m.select(at, at + 6); e.addBookmark('Target'); m.select(m.text.indexOf('.') + 1); e.focus(); }); + await onTab(page, 'references'); + await page.locator('#ribbon-panel-references').getByRole('button', { name: 'Insert Footnote', exact: true }).click(); + await expect(page.getByRole('textbox', { name: 'Footnote text' })).toBeFocused(); + await page.keyboard.type('See '); + await page.evaluate(() => { const e = window.__docs.current.view.engine; e.setHyperlink({ anchor: 'Target' }, 'the target'); }); + const state = () => page.evaluate(() => { const e = window.__docs.current.view.engine; return { path: e.session.path, range: [...e.model.range], target: e.session.body.text.indexOf('Target') }; }); + expect((await state()).path).toMatch(/footnotes\.xml#/); + await page.evaluate(() => { const e = window.__docs.current.view.engine, m = e.model; m.select(m.text.indexOf('the target') + 2); e.openHyperlink(); }); + await expect.poll(state).toMatchObject({ path: null }); + const after = await state(); + expect(after.range).toEqual([after.target, after.target]); +}); From af497f0088e839676ee2d677220b77fe13be69a8 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:06:05 -0700 Subject: [PATCH 104/186] Documents: Offer the document's bookmarks when making a link in a footnote, header or footer The link dialog listed the bookmarks of the story being edited, and notes, headers and footers have none, so a link from a footnote to a bookmark in the text could not be made. It now lists the body's bookmarks. --- src/documents/src/views/editor.js | 3 +- .../browser/story-internal-links.spec.js | 29 ++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/documents/src/views/editor.js b/src/documents/src/views/editor.js index 5666426ce..671106f03 100644 --- a/src/documents/src/views/editor.js +++ b/src/documents/src/views/editor.js @@ -282,11 +282,12 @@ export function editorView({ doc, goHome, newDoc, importFile, openPath, newFromT }; const host = el('div.canvas-host', { 'aria-label': 'Document pages' }); const surface = el('div.canvas-document-surface', {}, host); + // Bookmarks are in the body: a link made in a note, header or footer goes to one there. const editHyperlink = (anchor = hyperlink, options = {}) => { if (!(anchor instanceof HTMLElement)) anchor = hyperlink; if (!engine) return; engine.finishComposition(); if (!anchor.getClientRects().length) anchor = engine.input; - hyperlinkModal = openHyperlink({ anchor, ...options, link: engine.model.hyperlink?.link, bookmarks: engine.bookmarks, insert: engine.model.range[0] === engine.model.range[1] && !engine.model.hyperlink, + hyperlinkModal = openHyperlink({ anchor, ...options, link: engine.model.hyperlink?.link, bookmarks: engine.session.body.bookmarks, insert: engine.model.range[0] === engine.model.range[1] && !engine.model.hyperlink, apply: (target, text) => engine?.setHyperlink(target, text, { appearance: !engine.model.hyperlink }), remove: () => engine?.removeHyperlink(), open: () => engine?.openHyperlink() }); }; // A click on a link while editing shows where it goes, to follow, edit or remove it. diff --git a/src/documents/tests/browser/story-internal-links.spec.js b/src/documents/tests/browser/story-internal-links.spec.js index 90b87e66b..78a77473a 100644 --- a/src/documents/tests/browser/story-internal-links.spec.js +++ b/src/documents/tests/browser/story-internal-links.spec.js @@ -1,5 +1,7 @@ import { test, expect } from '@playwright/test'; -import { onTab } from './helpers/editor-tools.js'; +import { readFileSync } from 'node:fs'; +import { unzipSync, strFromU8 } from 'fflate'; +import { onTab, downloadDocx } from './helpers/editor-tools.js'; // A link to a bookmark followed from a footnote goes to that bookmark in the text (notes and headers hold no // bookmarks of their own), not to the start of the note. @@ -22,3 +24,28 @@ test('a bookmark link in a footnote leads to the bookmark in the text', async ({ const after = await state(); expect(after.range).toEqual([after.target, after.target]); }); + +test('the link dialog in a footnote offers the text\'s bookmarks', async ({ page }, info) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/mock.html#/new'); + await expect((await onTab(page, 'home')).getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + await page.locator('canvas.canvas-page').first().click({ position: { x: 150, y: 110 } }); + await page.keyboard.type('Claim here. Later the Target section.'); + await page.evaluate(() => { const e = window.__docs.current.view.engine, m = e.model, at = m.text.indexOf('Target'); m.select(at, at + 6); e.addBookmark('Target'); m.select(m.text.indexOf('.') + 1); e.focus(); }); + await onTab(page, 'references'); + await page.locator('#ribbon-panel-references').getByRole('button', { name: 'Insert Footnote', exact: true }).click(); + const note = page.getByRole('textbox', { name: 'Footnote text' }); + await expect(note).toBeFocused(); + await page.keyboard.type('See the target'); + await page.keyboard.press('Shift+ArrowLeft'); await page.keyboard.press('Shift+ArrowLeft'); + await page.keyboard.press('ControlOrMeta+k'); + await page.getByRole('combobox', { name: 'Link destination type', exact: true }).selectOption('bookmark'); + await expect(page.getByRole('combobox', { name: 'Link bookmark', exact: true }).locator('option')).toHaveText(['Start of document', 'Target']); + await page.getByRole('combobox', { name: 'Link bookmark', exact: true }).selectOption('"Target"'); + await page.getByRole('button', { name: 'Apply', exact: true }).click(); + // The note saves with a link to the bookmark in the text. + const pending = page.waitForEvent('download'); + await downloadDocx(page); + const path = info.outputPath('note-link.docx'); await (await pending).saveAs(path); + expect(strFromU8(unzipSync(readFileSync(path))['word/footnotes.xml'])).toMatch(/]*>.*et<\/w:t>/); +}); From b1a493405c6e84cd2a0638131bb6b52a0e582d8b Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:07:38 -0700 Subject: [PATCH 105/186] Documents: Keep the caret on its text when the mail merge preview changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previewing results, stepping to another recipient or turning the preview off put the caret back at the same character offset, so after a field whose result changed length the caret landed on other text (after «First Name» became Rosa it moved eight characters on). The selection is now carried across each replaced result. --- src/documents/src/engine/mail-merge-model.js | 8 ++++++-- src/documents/tests/mail-merge-docx.test.js | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/documents/src/engine/mail-merge-model.js b/src/documents/src/engine/mail-merge-model.js index a17ad4782..757987582 100644 --- a/src/documents/src/engine/mail-merge-model.js +++ b/src/documents/src/engine/mail-merge-model.js @@ -117,10 +117,14 @@ export function applyMergeResults(model, fields, results) { if (text.slice(f.range.start, f.range.end) !== shown) changes.push({ range: f.range, text: shown }); }); if (!changes.length) return 0; - const tracking = model.trackChanges, selection = { ...model.selection }; + const tracking = model.trackChanges; + // The selection stays on its text: a longer or shorter result before it moves it along. + let { anchor, focus } = model.selection; model.trackChanges = false; try { for (const { range, text: value } of changes.reverse()) { + const map = at => at <= range.start ? at : at >= range.end ? at + value.length - (range.end - range.start) : range.start + Math.min(at - range.start, value.length); + anchor = map(anchor); focus = map(focus); model.select(range.start, range.end); model.typingFormat = {}; model.typingStyle = null; model.typingDirectCleared = false; if (range.start === range.end && !value) continue; @@ -136,7 +140,7 @@ export function applyMergeResults(model, fields, results) { } } finally { model.trackChanges = tracking; } const max = model.text.length; - model.select(Math.min(selection.anchor, max), Math.min(selection.focus, max)); + model.select(Math.min(anchor, max), Math.min(focus, max)); model.didChange(); return changes.length; } diff --git a/src/documents/tests/mail-merge-docx.test.js b/src/documents/tests/mail-merge-docx.test.js index 620eda360..a89d448cb 100644 --- a/src/documents/tests/mail-merge-docx.test.js +++ b/src/documents/tests/mail-merge-docx.test.js @@ -244,6 +244,22 @@ describe('editing merge fields in the model', () => { // The remembered formatting is not written to the file. expect(strFromU8(unzipSync(await save(pkg, d))['word/document.xml'])).not.toContain('resultFormatting'); }); + it('keeps the caret on the same text when a preview changes the field results before it', async () => { + const { d } = await blankModel('Dear '); + d.select(5); insertMergeField(d, ' MERGEFIELD "First Name" '); d.replace(', thank you.'); + const caretBefore = text => { const at = d.text.indexOf(text); d.select(at); }; + const caretText = () => d.text.slice(d.range[0], d.range[0] + 5); + caretBefore('thank'); + applyMergeResults(d, mergeFieldRanges(d), ['Rosa']); + expect(d.text).toBe('Dear Rosa, thank you.'); + expect(caretText()).toBe('thank'); + applyMergeResults(d, mergeFieldRanges(d), ['Bartholomew-Alexander']); + expect(caretText()).toBe('thank'); + // A selection across the field keeps both ends on their text. + d.select(0, d.text.indexOf('you')); + applyMergeResults(d, mergeFieldRanges(d), placeholderResults(mergeFieldRanges(d))); + expect(d.text.slice(...d.range)).toBe('Dear «First Name», thank '); + }); it('builds label sheets that open editable and propagates the first label', async () => { const layout = LABEL_LAYOUTS.find(l => l.id === 'letter-10'); expect(layoutProblems(layout)).toEqual([]); From f6faf69edd88b1628a4ec7b23624fae9fbe1926a Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:12:12 -0700 Subject: [PATCH 106/186] =?UTF-8?q?Documents:=20Number=20captions=20labell?= =?UTF-8?q?ed=20in=20any=20script,=20such=20as=20=D0=A0=D0=B8=D1=81=D1=83?= =?UTF-8?q?=D0=BD=D0=BE=D0=BA=20or=20=E5=9B=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caption labels and SEQ names had to be ASCII letters. Localized word processors number captions as SEQ Рисунок, SEQ Abbildung or SEQ 図: such a document listed its own label in the Caption dialog, but inserting a caption with it failed ("Use a label of letters and numbers"), and New Label refused such words. Letters and digits of any script are now accepted, here and for a SEQ field in the Field dialog. --- src/documents/docs/conformance.md | 2 +- src/documents/src/engine/captions.js | 3 ++- src/documents/src/views/insert-dialogs.js | 2 +- src/documents/src/views/references.js | 4 ++-- .../tests/browser/caption-labels.spec.js | 23 +++++++++++++++++++ src/documents/tests/captions.test.js | 15 +++++++++++- 6 files changed, 43 insertions(+), 6 deletions(-) create mode 100644 src/documents/tests/browser/caption-labels.spec.js diff --git a/src/documents/docs/conformance.md b/src/documents/docs/conformance.md index 23a42610b..963ad93de 100644 --- a/src/documents/docs/conformance.md +++ b/src/documents/docs/conformance.md @@ -23777,7 +23777,7 @@ Note setting changes apply immediately and are saved, but are not part of undo. | `TOC` | §17.16.5.68 | Generated from `\o` ranges and `\u` outline levels, `\t` style-to-level lists, `\c`/`\a` caption sequences (tables of figures) and `\f`/`\l` TC entries, scoped by `\b`. `\h` links entries, `\n` omits page numbers (all or a level range), `\p` replaces the tab, `\z` is kept. Unknown switches are retained on rebuild. Leaders and right alignment are paragraph tab stops (§17.3.1.37). Only whole-paragraph results rebuild; others refresh page numbers only. | | `TC` | §17.16.5.63 | Hidden entry (no separator, no result); text, `\f`, `\l`, `\n` are read for tables of contents. | | `PAGEREF` | §17.16.5.45 | As before; Update Table can scope the refresh to one table's references. | -| `SEQ` | §17.16.5.56 | Captions write `\* ARABIC|alphabetic|ALPHABETIC|roman|ROMAN`; labels with spaces use underscores in the identifier. Chapter numbers (`\s`) are not evaluated. | +| `SEQ` | §17.16.5.56 | Captions write `\* ARABIC|alphabetic|ALPHABETIC|roman|ROMAN`; labels are letters and digits of any script (`SEQ Рисунок`), and labels with spaces use underscores in the identifier. Chapter numbers (`\s`) are not evaluated. | | `REF` | §17.16.5.51 | Cross-references to headings, bookmarks, whole captions, label and number, or caption text, optionally followed by a `\p` above/below reference. | | `NOTEREF` | §17.16.5.40 | New: evaluates the note number of the note reference inside its bookmark (`\h`, `\f`, `\p`). The formatted variant is inserted as superscript text. | | `CITATION` | §17.16.5.8 | Inline result generated from the bibliography part: tag, `\m` extra sources, `\p` pages, `\f` prefix, `\s` suffix, `\v` volume, `\n`/`\y`/`\t` suppression, `\l` kept. Style change refreshes every citation. | diff --git a/src/documents/src/engine/captions.js b/src/documents/src/engine/captions.js index 9b952b503..abfe1e5ff 100644 --- a/src/documents/src/engine/captions.js +++ b/src/documents/src/engine/captions.js @@ -42,7 +42,8 @@ export function captionLabels(doc, custom = []) { */ export function insertCaption(doc, { label = 'Figure', text = '', above = false, format = 'ARABIC', excludeLabel = false } = {}) { const id = sequenceId(label); - if (!/^[A-Za-z][A-Za-z0-9_]{0,39}$/.test(id)) throw new Error('Use a label of letters and numbers, such as Figure or Table.'); + // Letters of any script: localized word processors number captions as SEQ Рисунок, SEQ Abbildung, SEQ 図. + if (!/^\p{L}[\p{L}\p{M}\p{N}_]{0,39}$/u.test(id)) throw new Error('Use a label of letters and numbers, such as Figure or Table.'); if (!Object.hasOwn(CAPTION_FORMATS, format)) throw new Error('Choose a caption number format.'); let index; if (doc.tableContext && !doc.selectedImage) { diff --git a/src/documents/src/views/insert-dialogs.js b/src/documents/src/views/insert-dialogs.js index bc2748ee9..7a5df5d9d 100644 --- a/src/documents/src/views/insert-dialogs.js +++ b/src/documents/src/views/insert-dialogs.js @@ -120,7 +120,7 @@ export function openFieldDialog({ context, bookmarks = [], onInsert, onClose }) el('div.insert-field-preview', {}, el('span.field-label', {}, 'Field code'), code, el('span.field-label', {}, 'Result'), result))), ], onSubmit: () => { const entry = FIELD_CATALOG.find(f => f.name === names.value); - if (entry?.argument === 'identifier' && !/^[A-Za-z][\w]{0,39}$/.test(argument.value.trim())) throw new Error('Enter a sequence name of letters and numbers, such as Figure.'); + if (entry?.argument === 'identifier' && !/^\p{L}[\p{L}\p{M}\p{N}_]{0,39}$/u.test(argument.value.trim())) throw new Error('Enter a sequence name of letters and numbers, such as Figure.'); if (entry?.argument === 'bookmark' && !bookmark.value) throw new Error('This document has no bookmarks to refer to.'); return onInsert({ name: entry.name, instruction: instruction() }); } }); diff --git a/src/documents/src/views/references.js b/src/documents/src/views/references.js index 127a612b1..6faa2630f 100644 --- a/src/documents/src/views/references.js +++ b/src/documents/src/views/references.js @@ -223,10 +223,10 @@ export function createReferences({ editor, pkg, error, base = {}, onChange = () preview.textContent = `${exclude.box.checked ? '' : `${label.value} `}${sample[format.value]}${t ? `${/^[\s:.\-–—]/.test(t) ? '' : ': '}${t}` : ''}`; } const newLabel = el('button.btn.btn-outline', { type: 'button', onclick: () => { - const input = text('New label', '', { required: true, maxLength: 40, pattern: '[A-Za-z][A-Za-z0-9 _]{0,39}' }); + const input = text('New label', '', { required: true, maxLength: 40, pattern: '\\p{L}[\\p{L}\\p{M}\\p{N} _]{0,39}' }); openDialog({ title: 'New Label', body: [field('Label', input, 'Letters, numbers and spaces, such as Chart or Plate.')], onSubmit: () => { const value = input.value.trim().replace(/\s+/g, ' '); - if (!/^[A-Za-z][A-Za-z0-9 _]{0,39}$/.test(value)) throw new Error('Start the label with a letter and use letters, numbers and spaces.'); + if (!/^\p{L}[\p{L}\p{M}\p{N} _]{0,39}$/u.test(value)) throw new Error('Start the label with a letter and use letters, numbers and spaces.'); saveLabels([...customLabels(), value]); label.replaceChildren(...labels().map(l => el('option', { value: l }, l))); label.value = value; sync(); } }); diff --git a/src/documents/tests/browser/caption-labels.spec.js b/src/documents/tests/browser/caption-labels.spec.js new file mode 100644 index 000000000..bcab7cdd6 --- /dev/null +++ b/src/documents/tests/browser/caption-labels.spec.js @@ -0,0 +1,23 @@ +import { test, expect } from '@playwright/test'; +import { onTab } from './helpers/editor-tools.js'; + +// A new caption label can be a word in any script (Рисунок, Abbildung, 図), as localized word processors use. +test('a caption label in another script can be made and numbered', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/mock.html#/new'); + await expect((await onTab(page, 'home')).getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + await page.locator('canvas.canvas-page').first().click({ position: { x: 150, y: 110 } }); + await page.keyboard.type('Схема сети'); + await onTab(page, 'references'); + await page.locator('#ribbon-panel-references').getByRole('button', { name: 'Insert Caption', exact: true }).click(); + const caption = page.getByRole('dialog', { name: 'Caption' }); + await caption.getByRole('button', { name: 'New Label…' }).click(); + const label = page.getByRole('dialog', { name: 'New Label' }); + await label.getByRole('textbox').fill('Рисунок'); + await label.getByRole('button', { name: 'OK', exact: true }).click(); + await expect(label).toBeHidden(); + await expect(caption.getByLabel('Label', { exact: true })).toHaveValue('Рисунок'); + await caption.getByLabel('Caption text').fill('Сеть'); + await caption.getByRole('button', { name: 'OK', exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.__docs.current.view.engine.session.body.text)).toBe('Схема сети\nРисунок 1: Сеть'); +}); diff --git a/src/documents/tests/captions.test.js b/src/documents/tests/captions.test.js index 2fc37ff3d..b9f9af31c 100644 --- a/src/documents/tests/captions.test.js +++ b/src/documents/tests/captions.test.js @@ -4,7 +4,7 @@ import { unzipSync, strFromU8 } from 'fflate'; import { DocxPackage, createDocx, WRITING_STYLES } from '../src/engine/docx.js'; import { TextDocument, paragraph } from '../src/engine/model.js'; import { bodyFieldRanges } from '../src/engine/body-fields.js'; -import { insertCaption, referenceTargets, insertCrossReference } from '../src/engine/captions.js'; +import { insertCaption, referenceTargets, insertCrossReference, captionLabels } from '../src/engine/captions.js'; import { updateSequenceFields } from '../src/engine/field-update.js'; import { footnoteOpeningRuns } from '../src/engine/footnotes.js'; @@ -34,6 +34,19 @@ describe('captions and cross-references', () => { expect(reopened.text).toBe(d.text); }); + it('numbers captions with a label in any script, as localized word processors write them (SEQ Рисунок)', async () => { + const { pkg, d } = await setup(); + d.select(d.text.indexOf('Chart two')); insertCaption(d, { label: 'Рисунок', text: 'Схема' }); + d.select(2); insertCaption(d, { label: 'Рисунок', text: 'Обзор' }); + d.select(d.text.length); insertCaption(d, { label: '图表', text: 'Data' }); + expect(d.text).toBe('Chart one\nРисунок 1: Обзор\nChart two\nРисунок 2: Схема\nConclusion\n图表 1: Data'); + expect(bodyFieldRanges(d.paragraphs).map(r => r.field.instruction)).toEqual([' SEQ Рисунок \\* ARABIC ', ' SEQ Рисунок \\* ARABIC ', ' SEQ 图表 \\* ARABIC ']); + const reopened = new TextDocument((await DocxPackage.open(await save(pkg, d))).paragraphs); + expect(captionLabels(reopened)).toEqual(expect.arrayContaining(['Рисунок', '图表'])); + expect(() => insertCaption(d, { label: '1st' })).toThrow(); + expect(() => insertCaption(d, { label: 'Figure-A' })).toThrow(); + }); + it('inserts a cross-reference to a caption that follows renumbering', async () => { const { pkg, d } = await setup(); d.select(d.text.indexOf('Chart two')); insertCaption(d, { label: 'Figure', text: 'Second chart' }); From 019338f06d7c26554397683866e4efa503f6ca98 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:13:56 -0700 Subject: [PATCH 107/186] Documents: Note in the conformance record that the Bookmarks dialog asks for a name starting with a letter --- src/documents/docs/conformance.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/documents/docs/conformance.md b/src/documents/docs/conformance.md index 963ad93de..55f5ddd0e 100644 --- a/src/documents/docs/conformance.md +++ b/src/documents/docs/conformance.md @@ -1861,7 +1861,7 @@ and accessible fill dialog were visually inspected. | Feature | Specification basis | Supported subset and evidence | | --- | --- | --- | | Paired range markers | ECMA-376 Part 1 §§17.13.6.1 `bookmarkEnd`, 17.13.6.2 `bookmarkStart`, 17.18.10 `ST_DecimalNumber` | Preserve paired zero-width markers in main-story paragraphs, table-cell paragraphs and supported hyperlink wrappers. Support points, nested/overlapping ranges and ranges spanning paragraphs or tables. Match integer IDs by numeric value using arbitrary-precision integers, preserving their source spelling on export. Require one start followed by one end per ID. Reject unmatched, reversed, duplicate-ID or malformed markers before editable open. Marker offsets are separate from text and run formatting. | -| Names and duplicates | §17.13.6.2 `name`, §22.9.2.13 `ST_String`; [MS-OI29500 §2.1.356](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oi29500/da8b8f59-2af1-49d9-8553-f0c1ff2fb18d) | Import and retain arbitrary XML-valid names, including hidden underscore names, Unicode, spaces and empty strings. Resolve duplicate names to the first start in document order, following ECMA's rule; preserve subsequent markers. The native reference instead uses the last duplicate, a documented interoperability difference. New/renamed names use a declared ASCII authoring subset: 1–40 letters/digits/underscores, first character a letter or underscore, unique exact name. This UI subset does not restrict existing names. | +| Names and duplicates | §17.13.6.2 `name`, §22.9.2.13 `ST_String`; [MS-OI29500 §2.1.356](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oi29500/da8b8f59-2af1-49d9-8553-f0c1ff2fb18d) | Import and retain arbitrary XML-valid names, including hidden underscore names, Unicode, spaces and empty strings. Resolve duplicate names to the first start in document order, following ECMA's rule; preserve subsequent markers. The native reference instead uses the last duplicate, a documented interoperability difference. New/renamed names use a declared ASCII authoring subset: 1–40 letters/digits/underscores, first character a letter or underscore, unique exact name; the Bookmarks dialog asks for a letter first, since underscore names are hidden bookmarks it does not list. This UI subset does not restrict existing names. | | Marker attributes and containers | §§17.13.6.1–2, 17.18.13 `ST_DisplacedByCustomXml` | Paired column hints outside tables are retained and ignored as specified. Within tables, collapsed markers in the same paragraph are accepted as point bookmarks with their column attributes preserved, matching the native reference. Validate this after pairing endpoints, not while reading the start alone. Noncollapsed column ranges use the active-column model below; incomplete or noninteger pairs remain guarded. Retain valid next/prev displacement hints; they have no effect because block custom-XML structures are not editable in this engine. Reject invalid hints, marker child content and unknown attributes. Body/cell/control block boundaries and direct table/row boundaries have the additional supported subsets below. | | Internal destinations | §17.16.22 `hyperlink`, `anchor`, `r:id` | With no `r:id`, an anchor names a bookmark in the current document. Preserve explicit empty strings separately from omitted anchors. A missing bookmark or omitted anchor leads to the document start. `r:id` retains precedence over an accompanying anchor. Creating/editing internal links writes `w:anchor` without adding an external relationship. Switching between destination types retains character formatting and wrapper attributes. Nonempty `docLocation` remains unsupported. | | Bookmark editing | Original editor policy over §§17.13.6.1–2 | Accessible Bookmarks controls add a point/range from the current selection, rename, remove or navigate. Rename updates internal links to the active named bookmark while leaving ignored anchors on external links unchanged. Removing a bookmark retains links, which then follow missing-target fallback (or the next duplicate). Undo/redo restores markers, names, references, source text and selections atomically. | From 2614e116902e3b81eb2b9bce6656c050b0fcd9a0 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:16:15 -0700 Subject: [PATCH 108/186] Documents: Explain that notes go in the text when the footnote shortcut is pressed in a header or footer Pressing the footnote or endnote shortcut while editing a header or footer closed it and inserted the note wherever the caret had last been in the body, out of sight. It now leaves the document unchanged and says that notes go in the text of the document, as the disabled ribbon button implies. --- src/documents/src/views/references-notes.js | 3 ++- .../tests/browser/note-in-header.spec.js | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 src/documents/tests/browser/note-in-header.spec.js diff --git a/src/documents/src/views/references-notes.js b/src/documents/src/views/references-notes.js index ef93456da..0aba86435 100644 --- a/src/documents/src/views/references-notes.js +++ b/src/documents/src/views/references-notes.js @@ -19,7 +19,8 @@ export function createNoteCommands(ctx) { const notes = await import('../engine/footnotes.js'); if (engine.session.path !== null) { if (notes.isNoteStoryKind(engine.pageStories?.stories[engine.session.path]?.kind)) throw new Error('Notes cannot contain other notes.'); - engine.editStory(null); + // The shortcut pressed in a header or footer used to leave it and put the note wherever the caret was last in the text. + throw new Error('Footnotes and endnotes go in the text of the document. Close the header or footer first.'); } const { TextDocument, paragraph } = await import('../engine/model.js'); const body = engine.session.body, styles = body.styles.definitions || {}; diff --git a/src/documents/tests/browser/note-in-header.spec.js b/src/documents/tests/browser/note-in-header.spec.js new file mode 100644 index 000000000..e08903087 --- /dev/null +++ b/src/documents/tests/browser/note-in-header.spec.js @@ -0,0 +1,22 @@ +import { test, expect } from '@playwright/test'; + +// Footnotes and endnotes belong to the text: the shortcut pressed in a footer says so rather than putting a +// note at wherever the caret was last left in the text. +test('the footnote shortcut in a footer explains instead of inserting a note elsewhere', async ({ page }) => { + await page.goto('/mock.html'); + const input = page.getByRole('textbox', { name: 'Document text' }); + await expect(input).toBeVisible(); + await page.getByLabel('Open DOCX file').setInputFiles('public/fixtures/section-page-numbers.docx'); + await expect(input).toHaveValue(/^Roman start, page 1/); + await expect(input).toHaveJSProperty('readOnly', false); + const before = await page.evaluate(() => { + const e = window.__docs.current.view.engine; + const path = e.storyLayout.lines.find(line => line.page === 0 && e.pageStories.stories[line.pageStory]?.kind === 'footer').pageStory; + e.model.select(5); e.editStory(path, 0); e.model.select(1); e.focus(); + return { path, body: e.session.body.text }; + }); + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+Alt+f' : 'Control+Alt+f'); + await expect(page.getByText('Footnotes and endnotes go in the text of the document', { exact: false })).toBeVisible(); + const after = await page.evaluate(() => { const e = window.__docs.current.view.engine; return { path: e.session.path, body: e.session.body.text }; }); + expect(after).toEqual(before); +}); From 5de076b7df115abe8b24029164f5c8aa2a05f703 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:20:05 -0700 Subject: [PATCH 109/186] Documents: Keep citations, captions and tables out of reach while a note, header or footer is edited Insert Citation, Bibliography, Insert Caption, the table of contents gallery and the Insert menu's Caption and Index and Tables were available while a footnote, header or footer was being edited, but what they insert goes into the text: they closed the note and inserted at the caret's last place in the body, out of sight. Like Cross-reference and Insert Table of Figures, they are now unavailable there. --- src/documents/src/views/references-toc.js | 6 +++-- src/documents/src/views/references.js | 9 ++++--- .../tests/browser/references-in-notes.spec.js | 27 +++++++++++++++++++ 3 files changed, 36 insertions(+), 6 deletions(-) create mode 100644 src/documents/tests/browser/references-in-notes.spec.js diff --git a/src/documents/src/views/references-toc.js b/src/documents/src/views/references-toc.js index d771e9074..78e73e779 100644 --- a/src/documents/src/views/references-toc.js +++ b/src/documents/src/views/references-toc.js @@ -13,6 +13,8 @@ export function createTocCommands(ctx) { const { editor, ready, runBody, error } = ctx; const engineToc = () => import('../engine/toc.js'); const bodyParagraphs = () => editor()?.session.body.paragraphs || []; + // A new table goes into the text: not while a header, footer or note is being edited. + const bodyReady = () => !!ready() && editor()?.session.path === null; let tocModule = null; engineToc().then(m => { tocModule = m; }).catch(() => {}); const tables = () => { try { return tocModule ? tocModule.tocFields(bodyParagraphs()) : []; } catch { return []; } }; @@ -36,7 +38,7 @@ export function createTocCommands(ctx) { const designs = tocModule?.TOC_DESIGNS || []; return [ { header: 'Automatic tables' }, - ...designs.map(design => galleryItem({ label: design.name, description: design.description, disabled: !ready(), focusEditor, onSelect: () => insertDesign(design), + ...designs.map(design => galleryItem({ label: design.name, description: design.description, disabled: !bodyReady(), focusEditor, onSelect: () => insertDesign(design), preview: tablePreview(SAMPLE.filter(r => r.level <= design.levels).map(r => ({ ...r, bold: design.boldTop && r.level === 1 })), { title: design.title, leader: design.leader, rightAlign: design.rightAlign !== false, pageNumbers: design.pageNumbers !== false }) })), ]; } @@ -46,7 +48,7 @@ export function createTocCommands(ctx) { return [ ...galleryItems(focusEditor), { separator: true }, - { label: 'Custom Table of Contents…', icon: 'toc', disabled: !ready(), onSelect: () => customDialog() }, + { label: 'Custom Table of Contents…', icon: 'toc', disabled: !bodyReady(), onSelect: () => customDialog() }, { label: 'Update Table…', icon: 'updateTable', disabled: !ready() || !has, onSelect: () => updateDialog(false) }, { label: 'Remove Table of Contents', icon: 'delete', disabled: !ready() || !has, onSelect: () => runBody((body, toc) => toc.removeTableOfContents(body, tableFor(toc, body, false)), { modules: ['toc'] }) }, ]; diff --git a/src/documents/src/views/references.js b/src/documents/src/views/references.js index 6faa2630f..b7575dd85 100644 --- a/src/documents/src/views/references.js +++ b/src/documents/src/views/references.js @@ -314,14 +314,15 @@ export function createReferences({ editor, pkg, error, base = {}, onChange = () const style = citations.styleControl(); const styleWrap = el('label.ribbon-spin.ref-style-wrap', { title: 'Citation style' }, el('span.ribbon-spin-label', {}, 'Style:'), style.control); const bibliography = menuButton('Bibliography', 'bibliography', () => citations.bibliographyItems(focusEditor), { size: 'small', text: 'Bibliography' }); - when(citation, () => !!ready()); when(manage, () => !!ready()); when(bibliography, () => !!ready()); + // A citation or bibliography goes into the text: unavailable in a header, footer or note, as Cross-reference is. + when(citation, bodyReady); when(manage, () => !!ready()); when(bibliography, bodyReady); when(style.control, () => { style.sync(); return !!ready(); }); // Captions const caption = command('Insert Caption', 'caption', () => captionDialog(), { size: 'large', text: 'Insert Caption' }); const figures = command('Insert Table of Figures', 'tableOfFigures', () => toc.figuresDialog(), { text: 'Insert Table of Figures' }); const updateFigures = command('Update Table of Figures', 'updateTable', () => toc.updateDialog(true), { text: 'Update Table' }); const xref = command('Cross-reference', 'crossReference', () => crossReferenceDialog(), { text: 'Cross-reference' }); - when(caption, () => !!ready()); when(figures, bodyReady); when(updateFigures, () => !!ready() && toc.hasFigures()); when(xref, bodyReady); + when(caption, bodyReady); when(figures, bodyReady); when(updateFigures, () => !!ready() && toc.hasFigures()); when(xref, bodyReady); // Index const markEntry = command('Mark Entry', 'markEntry', () => indexes.markEntryPanel(), { size: 'large', text: 'Mark Entry', shortcut: MOD_KEY === 'Cmd' ? 'Cmd+Option+Shift+X' : 'Alt+Shift+X' }); const insertIndex = command('Insert Index', 'index', () => indexes.insertIndexDialog(), { text: 'Insert Index' }); @@ -352,8 +353,8 @@ export function createReferences({ editor, pkg, error, base = {}, onChange = () { label: 'Cross-reference…', icon: 'crossReference', disabled: disabled || !body, onSelect: () => crossReferenceDialog() }, { separator: true }, { label: 'Footnote…', icon: 'footnote', disabled, onSelect: () => notes.notesDialog() }, - { label: 'Caption…', icon: 'caption', disabled, onSelect: () => captionDialog() }, - { label: 'Index and Tables', icon: 'toc', disabled, items: [ + { label: 'Caption…', icon: 'caption', disabled: disabled || !body, onSelect: () => captionDialog() }, + { label: 'Index and Tables', icon: 'toc', disabled: disabled || !body, items: [ { label: 'Index…', icon: 'index', onSelect: () => indexes.insertIndexDialog() }, { label: 'Table of Contents…', icon: 'toc', onSelect: () => toc.customDialog() }, { label: 'Table of Figures…', icon: 'tableOfFigures', onSelect: () => toc.figuresDialog() }, diff --git a/src/documents/tests/browser/references-in-notes.spec.js b/src/documents/tests/browser/references-in-notes.spec.js new file mode 100644 index 000000000..c10e4ea00 --- /dev/null +++ b/src/documents/tests/browser/references-in-notes.spec.js @@ -0,0 +1,27 @@ +import { test, expect } from '@playwright/test'; +import { onTab } from './helpers/editor-tools.js'; + +// Citations, captions, bibliographies and tables of contents are inserted into the text of the document. While a +// footnote is being edited their commands are unavailable (as Cross-reference already was), rather than leaving +// the note and inserting at the caret's last place in the text. +test('body-only References commands are unavailable while a footnote is edited', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/mock.html#/new'); + await expect((await onTab(page, 'home')).getByRole('button', { name: 'Bold', exact: true })).toBeEnabled(); + await page.locator('canvas.canvas-page').first().click({ position: { x: 150, y: 110 } }); + await page.keyboard.type('A claim.'); + await onTab(page, 'references'); + const ribbon = page.locator('#ribbon-panel-references'); + const button = name => ribbon.getByRole('button', { name, exact: true, includeHidden: true }); + const commands = ['Insert Citation', 'Bibliography', 'Insert Caption', 'Cross-reference']; + for (const name of commands) await expect(button(name), name).toBeEnabled(); + await button('Insert Footnote').click(); + await expect(page.getByRole('textbox', { name: 'Footnote text' })).toBeFocused(); + await page.keyboard.type('Source'); + for (const name of commands) await expect(button(name), name).toBeDisabled(); + await ribbon.getByRole('button', { name: 'Table of Contents', exact: true }).first().click(); + await expect(page.getByRole('menuitem', { name: /^Classic/ })).toBeDisabled(); + await page.keyboard.press('Escape'); + const text = await page.evaluate(() => window.__docs.current.view.engine.session.body.text); + expect(text).toBe('A claim.'); +}); From 5ea865b51bededfda7795d442c72e097808281d4 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 27 Oct 2026 22:24:04 -0700 Subject: [PATCH 110/186] Documents: Name links to files plainly in the read-only notice A document with a HYPERLINK field to a file, a relative path or a UNC path opens read-only, and the notice's list of unsupported features showed the link dialog's advice instead ("Enter an absolute http, https, mailto or tel link."), or for a UNC path ("\\\\server\\share") "an unsupported HYPERLINK field switch", since a quoted argument starting with a backslash was taken for a switch. It now reads "A HYPERLINK field to an address other than http, https, mailto or tel". --- src/documents/src/engine/docx-body-fields.js | 8 +++++--- src/documents/tests/hyperlink-fields.test.js | 5 +++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/documents/src/engine/docx-body-fields.js b/src/documents/src/engine/docx-body-fields.js index e3d94e744..579bfaa12 100644 --- a/src/documents/src/engine/docx-body-fields.js +++ b/src/documents/src/engine/docx-body-fields.js @@ -39,8 +39,9 @@ export function bodyFieldInstruction(instruction) { } let address,anchor,tooltip='',frame,bookmark; for(let i=1;i