diff --git a/src/documents/docs/canvas-engine.md b/src/documents/docs/canvas-engine.md
index d9c1ed439..ae3b43e10 100644
--- a/src/documents/docs/canvas-engine.md
+++ b/src/documents/docs/canvas-engine.md
@@ -671,7 +671,8 @@ This is a functional prototype, not feature-complete relative to the previous ed
- Page preview uses saved single-section dimensions, margins and gutter, and Noto Sans with run-specific sizes and a real italic face. Letter, A4, Legal and custom sizes are supported. Imported fonts and other unsupported formatting remain preserved without faithful preview. See the conformance record for page defaults, application limits and rejected structures.
- The toolbar offers left/center/right/justified alignment, preset/custom line-spacing multiples, exact/minimum point heights, and before/after spacing in points. Choosing a line-spacing preset replaces an imported point height with a multiple; Line spacing options can also restore inheritance. Mixed properties are shown individually. A selection ending at the start of another paragraph excludes that paragraph; a caret formats its current paragraph, including empty ones. Splits inherit the paragraph format; merges use the first paragraph's format.
- Line-spacing multiples use each line’s maximum run ascent/descent from the Noto font tables. Adjacent after/before gaps collapse to the larger value. Before-spacing that cannot fit with its first line is suppressed at a new page; trailing after-spacing does not create blank pages. The terminal-break paragraph-mark and leading-break before-spacing compatibility flags are implemented; the tab compatibility flags are implemented as well. Keep-with-next chains, keep-lines and widow/orphan control use measured line heights. Other compatibility flags and font fallback remain future work.
-- Paragraph spacing is limited to 0–600pt; line spacing to 0.5–5× or 6–600pt. DOCX spacing uses twentieths of a point and multiples use 240ths of a line. Contextual spacing is supported as described above. Justified/distributed alignment and effective automatic or line-unit before/after spacing are rejected before editing.
+- Paragraph spacing is limited to 0–600pt; line spacing to 0.5–5× or 6–600pt. DOCX spacing uses twentieths of a point and multiples use 240ths of a line. Contextual spacing is supported as described above. Effective automatic or line-unit before/after spacing is rejected before editing; justified and distributed alignment are supported (see the conformance record).
+- A page canvas never exceeds 4096 × 4096 device pixels (the canvas limit of Safari on iOS and iPadOS, which otherwise paints a blank page). A page zoomed in on a high-density display beyond that (a Letter page at 200% on a 3x phone) is painted with fewer device pixels per CSS pixel, the same on both axes; pointer, caret and selection geometry are unaffected (`src/engine/canvas-backing.js`).
- Canvas measurement delegates shaping to the browser. Ligature caret precision, bidirectional layout, broad script coverage and exact native pagination are unfinished.
- Each input event is an undo transaction; consecutive ordinary typing is not yet grouped into words.
- The entire document is laid out and repainted after each edit. There is a 200-page preview limit, a 100,000-character limit, a 20MB compressed input limit, and a 50MB expanded ZIP limit. These bounds are not a performance guarantee for large documents.
diff --git a/src/documents/docs/collaboration.md b/src/documents/docs/collaboration.md
index f7ef68fe6..1be459ef0 100644
--- a/src/documents/docs/collaboration.md
+++ b/src/documents/docs/collaboration.md
@@ -58,7 +58,15 @@ equal changes. So:
paragraph keep one value (Yjs's deterministic winner);
- tables, styles, list definitions and page settings are atomic registers: concurrent edits to
different tables or styles merge; to the same table, the last structural change wins while the
- text people typed in its cells stays (cells are body paragraphs).
+ text people typed in its cells stays (cells are body paragraphs);
+- a table cell's characters and marks are its own in the diff: one matches only the same character
+ of the same cell (the body's still match each other). A row or column added or removed therefore
+ inserts or deletes whole cells, rather than keeping another cell's mark or letters and relabelling
+ them, and text someone types in another cell meanwhile stays in that cell. New paragraphs of a
+ cell are written before its last mark, inside the cell, not where a new row or column would go;
+- a paragraph left in a cell that is gone (split in a cell while someone else deletes its row or
+ column) is read into the cell before it (at a table's start, the one after it; outside a table,
+ the body), without moving anything in the shared text, so it is kept and the table stays whole.
**Reading remote changes.** An update from someone else is applied to the Yjs document (after this
person's pending changes are written, so the diff never mistakes their text for a deletion), the
@@ -129,7 +137,9 @@ plus a notice; it composes with the document's own Restrict Editing guard.
**Offline.** Every local change is queued in IndexedDB (memory where unavailable) before it is sent
and kept until the room accepts it; the title bar shows *Offline · N waiting*; changes go out when
the connection returns, also after a reload of the same room in this browser (Yjs updates are
-idempotent, so a change delivered twice is harmless). Changes refused for good (access removed)
+idempotent, so a change delivered twice is harmless). Queued changes name the account that made
+them: only that account's next visit to the room sends them, so someone else signed in on the same
+computer neither sends them as theirs nor has them refused and thrown away. Changes refused for good (access removed)
are dropped and the person is told. In a tab that does not save the file (a guest's), changes the
room has not received exist nowhere else: closing the tab warns, and leaving the document sends
them first and asks when they cannot go out.
@@ -212,6 +222,10 @@ Puter's AI (the playground) the pane, its buttons and menu items are not offered
- Concurrent changes to the same table's structure keep one person's structure (atomic register);
concurrent creation of new list definitions with the same numbering id keeps one definition.
+- Typing into an empty cell (or at the very start of a cell) right where someone else inserts a row
+ or column at the same moment shares one place in the shared text with the new cells; Yjs orders
+ the two insertions by client, so that text can land in one of the new cells. Text typed in a cell
+ someone else deletes stays, in the cell after it.
- Header, footer and note stories are atomic per story: two people editing one header at once keep
one person's version.
- Document-level settings outside the model (Restrict Editing's protection, review settings,
diff --git a/src/documents/docs/conformance.md b/src/documents/docs/conformance.md
index 3c6037cc0..40d8a2179 100644
--- a/src/documents/docs/conformance.md
+++ b/src/documents/docs/conformance.md
@@ -1483,15 +1483,24 @@ and the clauses above, including the
[soft-return definition](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessing.donotexpandshiftreturn?view=openxml-3.0.1).
The review distinguishes word justification from `distribute`, which also expands
characters, and requires Arabic kashida behavior that this engine does not yet
-implement. Arabic text in a justified paragraph therefore rejects on import or
-rolls back an editing transaction.
+implement. Arabic text in a justified paragraph is justified by its word spaces,
+exactly as Latin text: it imports editable and typing Arabic into a justified
+paragraph is accepted. This is a declared approximation: the reference
+application can lengthen Arabic joins with kashida (tatweel, U+0640) instead
+of, or as well as, widening spaces, so justified Arabic lines reach the same
+margins with different inter-word gaps and no elongated joins. No kashida
+characters enter the source text (`tests/canvas-justification.test.js`,
+`tests/browser/justification.spec.js`).
Declared preview policy: expansion opportunities are interior U+0020 spaces and
U+00A0 nonbreaking spaces. Leading/trailing spaces and other fixed Unicode spaces,
including narrow NBSP, retain natural advances. The existing trailing-space
compatibility flags still govern wrapping, hanging and underlining. With tabs,
-only the final left-aligned field expands; earlier fields stay anchored. Actual
-expansion after center/right/decimal tabs rejects before a save handler attaches.
+only the final left-aligned field expands; earlier fields stay anchored. A line
+whose final field follows a center, right or decimal tab keeps its natural
+spacing, so that field stays aligned on its stop, and the document remains
+editable. This is a declared approximation: the reference application's rule
+for such lines was not measured.
The local playground measures the incoming layout before replacing its current
editor, so geometry-based import failures preserve the open document.
Native advances are remeasured when rounding exceeds the writing edge, reducing
@@ -1521,8 +1530,19 @@ of fractional cell widths, text scaling, character spacing and small caps with
superscript. Screenshots cover rich paragraphs, both soft-return policies and
continued tables with repeated headers.
-**Remaining gaps:** distributed and Thai justification, Arabic kashida, expansion
-after center/right/decimal tabs, language-specific justification rules and exact
+Distributed alignment (`w:jc w:val="distribute"`, §17.18.44 "Distribute All
+Characters Equally") imports, edits and lays out: every line, the paragraph's
+last line and lines ending in manual breaks included, is spread between both
+margins by widening the gap after every character of the line equally (spaces
+included), as native Canvas letter spacing in measurement and painting. Leading
+and trailing spaces, tab fields before the last one and inline pictures keep
+their advances; a line of one character stays at the start edge. The Paragraph
+and style dialogs offer Distributed; the Justify toolbar button is not pressed
+for it. `tests/distributed-alignment.test.js` and
+`tests/browser/justification.spec.js` cover widths, carets, import and editing.
+
+**Remaining gaps:** Thai distribution (`thaiDistribute`), Arabic kashida (justified Arabic widens spaces only), expansion
+after center/right/decimal tabs (such lines stay natural), language-specific justification rules and exact
reference spacing across compatibility versions. No reference application was available
for visual comparison. Browser checks cover current Chromium; other browsers
are capability checked but not visually certified. A schema pass is not full
@@ -1861,7 +1881,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 authoring subset: 1–40 UTF-16 units of letters and digits of any script (with their combining marks) or underscores, first character a letter or underscore, no spaces or punctuation, 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. |
@@ -2105,7 +2125,7 @@ were visually inspected. No reference rendering comparison was performed.
| Feature | Specification basis | Supported subset and evidence |
| --- | --- | --- |
-| Single and double forms | ECMA-376 Part 1 §§17.3.2.37 `strike`, 17.3.2.9 `dstrike`; §17.17.4 common booleans | Read single/double on and explicit false separately. Missing declarations inherit; absence throughout means no strikethrough. Accept missing `val` as true and `0/1/false/true/off/on`. Reject duplicate elements, malformed booleans, nested content and unknown WordprocessingML attributes. Reject both elements in the same active property set, including explicit false combinations, rather than silently choose a conflicting declaration. |
+| Single and double forms | ECMA-376 Part 1 §§17.3.2.37 `strike`, 17.3.2.9 `dstrike`; §17.17.4 common booleans | Read single/double on and explicit false separately. Missing declarations inherit; absence throughout means no strikethrough. Accept missing `val` as true and `0/1/false/true/off/on`. Reject duplicate elements, malformed booleans, nested content and unknown WordprocessingML attributes. Both elements in one property set are accepted when at most one is on (both off, or one on and the other off) and resolve as a pair, each declaration independently (see "Paired strike declarations" below); both on in one property set is rejected rather than silently choosing a form. |
| Style inheritance | §§17.7.2–17.7.3; existing MS-OI29500 style-toggle compatibility policy recorded below | Single is a toggle; double is not in the §17.7.3 toggle list. Use the nearest declaration of each form in a same-type `basedOn` chain. Paragraph/table single declarations reset, character single declarations toggle relative to the document default, and direct declarations are absolute. Double on remains on through a child style declaring on. An off declaration affects its own form. Support document defaults, unconditional table styles, paragraph/character styles, empty marks, direct runs and numbering labels. |
| Cross-form precedence | Declared compatibility interpretation of the mutually exclusive appearance requirement | A positive declaration selects its form over the previously resolved form. Resolve nearest single/double declarations in their style-chain order, then continue through the hierarchy. This provides one visual form and supports direct switching over inherited formatting. ECMA does not explicitly prescribe competing forms across different property sets; this precedence is an inference, not verified reference behavior. |
| Editing and serialization | §§17.3.1.29, 17.3.2.28, 17.9.24; Part 4 §A.2 ordered run properties | Accessible icon buttons select single/double or clear the active form; mixed selections and future typing are supported. Native `formatStrikeThrough` input uses the same mutation/history path. Shared-style controls preserve four distinct on/off declarations and Inherit. A direct clear writes the false declaration for the inherited form, or single false when no inherited form exists. Switching removes the opposing source element; no new property set contains both. Explicit source declarations and unused invalid styles remain untouched until changed/exposed. |
@@ -2523,7 +2543,7 @@ that waits for the old editor to detach before checking the reopened document.
| Feature | Specification basis | Supported subset and evidence |
| --- | --- | --- |
| Direct highlighting | ECMA-376 Part 1 §17.3.2.15 `highlight`, §17.18.40 `ST_HighlightColor`, §A.1 `CT_Highlight` | All 16 named colors and explicit `none`, with the standard's sRGB values (including `darkBlue=00008B`, `darkGreen=006400`, `darkGray=A9A9A9`, `lightGray=D3D3D3`). The anchored Text highlight swatch palette applies to a selection, an empty paragraph mark or future typing. No highlight writes `none` and reveals the underlying background. Invalid/missing values, duplicate elements, nested content and unknown highlight attributes reject before editing. |
-| Implementation-note restrictions | [MS-OI29500 §2.1.79](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oi29500/e07a90e2-76b5-47dd-8bf4-668a806177c7), referring to [§2.1.245](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oi29500/9f0ca7a0-5e6a-487e-bb52-2b74d4bb614a), [§2.1.248](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oi29500/d0244b61-fd96-45f0-ac84-7380d2b6d663), [§2.1.260](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oi29500/0f36799f-599e-449b-8d9d-cd42c9c6e41f), [§2.1.291](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oi29500/ffb4a990-b46f-4e38-8db3-96fc13d0b0f6) | Unlike the base schema, the native reference disallows highlight in document defaults, named styles, conditional table styles and numbering-level run properties. The supported subset therefore authors direct highlighting only. Active default/style/level declarations reject; unused definitions remain preserved and cannot be applied. Style-edit APIs reject highlight patches. Existing direct highlighting survives supported paragraph/character style changes. |
+| Implementation-note restrictions | [MS-OI29500 §2.1.79](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oi29500/e07a90e2-76b5-47dd-8bf4-668a806177c7), referring to [§2.1.245](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oi29500/9f0ca7a0-5e6a-487e-bb52-2b74d4bb614a), [§2.1.248](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oi29500/d0244b61-fd96-45f0-ac84-7380d2b6d663), [§2.1.260](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oi29500/0f36799f-599e-449b-8d9d-cd42c9c6e41f), [§2.1.291](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oi29500/ffb4a990-b46f-4e38-8db3-96fc13d0b0f6) | Unlike the base schema, the native reference disallows highlight in document defaults, named styles, conditional table styles and numbering-level run properties. The supported subset therefore authors direct highlighting only. A declaration of `none`, which highlights nothing, is accepted and kept; other active default/style/level declarations reject; unused definitions remain preserved and cannot be applied. Style-edit APIs reject highlight patches. Existing direct highlighting survives supported paragraph/character style changes. |
| Background precedence and shading | §17.3.2.32 run `shd`, §17.3.5 `CT_Shd`, §17.18.78 `ST_Shd`, §17.7.2 hierarchy | Highlight supersedes run shading. Import flat `clear` fill and `solid` foreground, including RGB, Automatic, mapped themes and HSL tint/shade. Shading declarations inherit atomically through defaults, table/paragraph/character styles and direct properties. The existing theme resolver handles `themeFill`, `themeFillTint` and `themeFillShade` independently of foreground attributes. `clear` Automatic exposes the page/cell background; `solid` Automatic chooses contrasting black/white. Patterned and `nil` run shading reject, including when a highlight would hide them. Paragraph shading is supported by the later flat paragraph-shading iteration above. |
| Geometry and paint | Existing source-offset/layout contracts; §17.3.1.33 line spacing | Background rectangles use measured source advances and the run's font/image ascent and descent. Include spaces, aligned tab gaps and inline-image reserved slots; transparent image pixels reveal the highlight. Exact line spacing clips backgrounds with text. Color/highlight boundaries share shaped text and do not change advances, AutoFit demand, caret stops, selection, line breaks or pagination. Repeated header copies share source formatting. Empty marks and undisplayed breaks have no artificial background width. |
| Foreground and selection | §17.3.2.6 automatic foreground; application selection policy | Automatic foreground/underlines respond to run shading and the underlying paragraph/cell/page fill, ignoring highlight overlays as confirmed by the independent page-background reference controls. Paint page/cell fills, run backgrounds, translucent selection, then text/images/underlines. Selection remains visible over highlighting without washing out automatic white text on a dark background. List-label backgrounds follow paragraph-mark formatting and supported level shading; no highlight is written into numbering definitions. |
@@ -2934,10 +2954,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/),
@@ -3523,7 +3543,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
@@ -3608,7 +3628,7 @@ new page-placement contracts.
| Hanging-indent tab | Part 1 §17.3.1.38; Part 4 §14.8.3.31 `noTabHangInd` | A hanging indent supplies an implicit left tab at the paragraph's continuation edge, unless the retained compatibility flag disables it. An explicit stop at that position keeps its alignment. |
| Alignment after tabs | Part 4 §14.8.3.21 `forgetLastTabAlignment` | Shape tab fields before aligning the whole line. With this retained flag enabled, omit the gap from the last non-left tab when computing the paragraph-alignment shift. |
| Decimal fields | Part 1 §17.18.84; MS-OE376 §2.1.589 | Align around the first period, including across formatting runs. Without a period, use the end of the first number (before a suffix), or the field's end if there is no number. Other decimal separators/locales remain a stated gap. |
-| Character-unit overrides | MS-OI29500 §2.1.43 | Zero character-unit indents disable earlier character-unit indents in the native reference; retain those explicit zeros on edit. Effective nonzero character-unit indents are rejected instead of being approximated as physical distances. |
+| Character-unit overrides | MS-OI29500 §2.1.43 | Zero character-unit indents disable earlier character-unit indents in the native reference; retain those explicit zeros on edit. Effective nonzero character-unit indents are resolved as described in "Character-unit indents" below. |
| Editing and geometry | Application behavior | Tab is one source character and one real layout gap. Carets, selection, hit testing, underline gaps and line metrics use its geometry. Aligned fields reshape as text grows; wrapping resumes at the continuation indent. Indents/custom tabs and the document default can change in one undo transaction. Unchanged mixed fields and exact imported twips survive the dialog. |
Primary references:
@@ -3647,7 +3667,7 @@ are bounded by the indented right edge; an oversized aligned field cannot move
backwards over its preceding field, and overflow wraps at word/grapheme boundaries.
Exact reference behavior for overflowing/overlapping fields has not been verified.
Tab leaders are supported by the later iteration above. Bar tabs, mirrored
-indents and effective nonzero character-unit indents are rejected. Locale-specific decimal separators are not implemented;
+indents are rejected; effective nonzero character-unit paragraph indents are resolved ("Character-unit indents" below), while character-unit indents on numbering levels are still rejected. Locale-specific decimal separators are not implemented;
the dialog explicitly identifies period alignment. Other existing typography and
pagination gaps below still apply.
@@ -7239,6 +7259,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
@@ -7322,9 +7350,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
@@ -7447,6 +7485,36 @@ was re-exported and produced pixel-identical pages. Validation: 88 unit tests,
schema checks, and production build/license checks pass. The corrected fixture
also passed its six unit cases and browser case on rerun.
+### Update Fields in an edited header or footer
+
+Pressed while a header or footer is being edited, Update Fields (F9) also refreshes that
+story's fields that the field evaluator computes (DATE, TIME, FILENAME, AUTHOR,
+DOCPROPERTY, NUMWORDS… per §17.16.5). A story field stays one atomic run holding its
+saved envelope (§§17.16.18, 17.16.19): the new result replaces the cached result runs
+inside the original delimiters, keeping the first result run's properties, and saves as
+such. PAGE, NUMPAGES and SECTIONPAGES follow pagination as before; locked fields
+(`fldLock`), and fields the evaluator does not compute or whose envelope it cannot
+rewrite, keep their cached result. Only the open story is updated, in one undo step.
+
+### Editable page-number building blocks
+
+Word's Insert > Page Number designs save a header or footer whose paragraphs sit in
+one or two nested block-level `w:sdt` wrappers with `w:docPartObj`/`w:docPartGallery`
+"Page Numbers (Top of Page)" or "(Bottom of Page)" (§§17.5.2.29, 17.5.2.13). A story
+whose controls are all such block wrappers, with only `id`, `alias`, `tag`, `rPr` or an
+unlocked `lock` besides `docPartObj`, is now read through the body's block-control
+reader instead of being projected: it is editable, and saving writes each wrapper's
+original shell (properties and `sdtEndPr`) around its current paragraphs; an
+unedited story keeps its part byte for byte. Any other control in a header or
+footer (inline, locked, rich text, plain text…), and notes, keep the retained
+behavior above. If the control reader refuses a wrapper the story falls back to
+retained display. Word's "Watermarks" building block (a VML watermark beside a
+page-number block) is the same kind of wrapper; such wrappers' `w:id` values may repeat
+within a story or across copied header parts, since the id stays in the retained shell
+and identifies nothing else. The independently authored `page-number-parts.docx`
+(`tests/fixtures/page-number-parts`) covers Top of Page, Bottom of Page with
+`sdtEndPr`, and a nested "Page X of Y" footer with PAGE and NUMPAGES.
+
## Retained page-story tables
ECMA-376 Part 1 §§17.10.2/17.10.5 govern footer/header references; §17.4.38
@@ -7881,7 +7949,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.
@@ -8843,6 +8913,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.
@@ -12259,7 +12331,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
@@ -21755,8 +21833,10 @@ conversion and the paste (2026-10-16).
Markdown (.md, with GitHub tables, task lists and strikethrough) and HTML files
now open as new documents instead of the failure message. Markdown is rendered
-with the existing mdast libraries with raw HTML disabled; HTML uses the importer's
-existing body cleanup. Both then go through the formatted-paste conversion:
+with the existing mdast libraries; raw HTML in it is kept and goes through the same
+body cleanup as an HTML file (hidden content, scripts, styles, forms and embedded
+content are left out; updated 2026-10-27). HTML uses the importer's existing body
+cleanup. Both then go through the formatted-paste conversion:
headings become Heading 1–6 (§17.7.4), lists become bullet or numbered paragraphs
with nesting levels (§17.9), and bold, italic, underline, strikethrough,
super/subscript, color and http(s), mailto and tel links become run properties and
@@ -21767,6 +21847,13 @@ many were left out). Loading adds no undo step;
the document autosaves like other imports. Browser regressions cover Markdown and
HTML structure, links, script removal, autosave, export and reopen.
+### Horizontal rules in imported HTML and Markdown (2026-10-27)
+
+An `
` (and Markdown's `---`, `***` thematic breaks) opens as an empty paragraph
+with a single bottom border (`w:pBdr/w:bottom`, §17.3.1.7, size 6, space 1), as
+Word draws a rule. A rule at the very start or end of the file is left out with
+the other leading and trailing empty blocks.
+
### Style-sheet formatting in imported HTML (2026-10-26)
HTML that sets formatting through its style sheet rather than inline styles
@@ -22102,7 +22189,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.
@@ -22132,6 +22220,10 @@ 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.
+- A move (§17.13.5.21–.24 `moveFrom`/`moveTo` with their `moveFromRangeStart`/`moveToRangeStart` name) is one change:
+ accepting or rejecting either half, at the caret, in a selection or from its balloon, resolves both halves.
+- 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,
@@ -22139,6 +22231,16 @@ Supported subset:
- Updating fields (Update Fields, Update page references, mail merge previews) is not itself tracked; a
result that was wholly a tracked insertion or deletion keeps that change after the update. The field's
begin/separate/end characters are written outside `w:ins`/`w:del` (an approximation).
+- Table structure: the Delete key on selected cells records their text as deleted (the cells stay).
+ Edits that remove or rearrange table text as a whole are not recorded as revisions (row deletion,
+ §17.13.5.13 `w:trPr/w:del`, and cell revisions, §17.13.5.4 `w:cellDel` / §17.13.5.5 `w:cellMerge`,
+ are not written), so while tracking is on Delete Rows, Delete Columns, Delete Table, Delete Cells,
+ Merge Cells (including Split Cells over several cells and the table eraser), table Sort, Convert Text
+ to Table and Convert Table to Text refuse with "… cannot be tracked yet. Turn off Track Changes to do
+ this.", as paragraph Sort and outline moves do, instead of removing text a reviewer could not see or
+ reject. Inserting rows, columns and cells stays available; a Quick Table may merge the cells of the
+ table it has just inserted. Tests: `tests/table-editing.test.js` ("with Track Changes on"),
+ `tests/browser/table-block-tracked-cut.spec.js`.
- All Markup (inline colour, underline and strikethrough with left-margin change bars), Simple
Markup and No Markup (deleted text hidden), and Original (inserted text hidden).
- Unchanged documents save byte for byte; revisions survive untracked edits and round trips.
@@ -22350,7 +22452,11 @@ screen only: · spaces, ° nonbreaking spaces, → tabs, ↵ text-wrapping break
¶ paragraph ends, ¤ cell ends, and labelled dotted rules for page, column and
section breaks (labelled by how the next section starts, §17.6.22). Marks never
reach print, PDF or the saved document. Headers, footers and text boxes do not
-show marks yet; hidden text (§17.3.2.14) and optional hyphens are not marked.
+show marks yet; hidden text (§17.3.2.14) is not marked. Optional hyphens
+(§17.3.3.29 `softHyphen`, and U+00AD in text) show as a small raised ¬ over a
+hairline at their position where they do not break the line; where one does,
+its hyphen is drawn. Unlike the reference application, the text is not reflowed
+to give the mark its own width.
The [independent control](../tools/home_editing_fixture.py)
(`public/fixtures/home-editing.docx`) has mixed runs, a character style with
@@ -23230,7 +23336,9 @@ Supported subset:
convention for numbered drafts and a declared choice where the clause is silent.
- Labels are right-aligned at `distance` left of the line's column (automatic:
0.25 in; in multi-column sections at most half the column gap, minimum
- 0.0625 in), on the line's baseline, in the "line number" character style over
+ 0.0625 in; with a line between columns, an automatic label of a later column
+ that would come within 2 px of that line is centered between the line and its
+ column instead, a preview policy), on the line's baseline, in the "line number" character style over
the default paragraph style (else the document defaults). They paint on screen,
in print and in the rasterized PDF pages.
- `suppressLineNumbers` is resolved through paragraph styles and direct
@@ -23746,7 +23854,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. |
@@ -23905,7 +24013,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
@@ -23946,8 +24056,11 @@ Ink is saved as ordinary DrawingML so every reader shows it; nothing depends on
`wp:posOffset`, relative to the paragraph that starts on the page at or above the ink (the run
carrying the drawing is inserted at that paragraph's start), so ink moves with its text. Editing a
group keeps its anchor paragraph and shifts the offsets; groups authored with `wp:align` or
- percentage offsets are rewritten with page-relative offsets when edited. Pages on which no body
- paragraph starts (one paragraph spanning the whole page, or a page of table) cannot take new ink.
+ percentage offsets are rewritten with page-relative offsets when edited. On a page where no body
+ paragraph starts, the ink is anchored in a table cell paragraph that starts on the page (in a cell,
+ `relativeFrom="page"` is measured from the cell, `layoutInCell="1"`), or else its run goes at the
+ start of the page's first line of a paragraph running on from an earlier page, with
+ `wp:positionV relativeFrom="line"` (§20.4.3.5) so the ink stays on the page it was drawn on.
- Each stroke is a `wps:wsp` whose `a:custGeom` (§20.1.9.8) has one `a:path` (§20.1.9.15) tracing the
stroke's filled outline with `a:moveTo`/`a:lnTo`/`a:close` in a 1/16-pixel path grid
(`w`/`h`), empty `avLst`/`gdLst`/`ahLst`/`cxnLst`, `a:rect l t r b`, and `a:ln` with `a:noFill`.
@@ -24390,3 +24503,45 @@ 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). |
+| 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. Korean, Hebrew, Arabic and iroha formats are added in the next row; other formats (Hindi, Thai, Vietnamese, Russian, the half-width 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 `thaiCounting`. |
+| 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`. |
+| Paired strike declarations | ECMA-376 Part 1 §17.3.2.37 `strike`, §17.3.2.9 `dstrike`, §17.17.4 on/off values; §17.7.2–17.7.3 style hierarchy | Word, Pages and legal templates write `` (or one form on with the other off) in runs, styles and list levels. That opened the whole document read-only ("strike and dstrike on the same property set"), a policy that treated a pair of off declarations as a conflict. A pair is now one declaration of both forms (`bothStrikeOff`, `singleStrikeDoubleOff`, `doubleStrikeSingleOff`), resolved like the paired capitalization flags: each half applies to its own form independently, including character-style toggles for single strike, and the form left on is shown (none if neither). Untouched XML is kept; a style edited to a pair writes both elements; a direct change writes one form as before. Both forms on in one property set still opens read-only. The style dialog offers the three pairs. Unit regression `tests/strike-pairs.test.js` (direct, paragraph-style, character-style and list-level pairs; on/off mixes; edit, save, reopen; both on refused); `tests/canvas-strikethrough.test.js` and `tests/browser/strikethrough.spec.js` now use both on as the conflicting form; the export passes `check:conformance`. |
+| Character-unit indents | ECMA-376 Part 1 §17.3.1.12 `ind` (`leftChars`/`startChars`, `rightChars`/`endChars`, `firstLineChars`, `hangingChars`: hundredths of a character unit, overriding `left`, `right`, `firstLine` and `hanging` when present); §17.7.2 style hierarchy | Chinese and Japanese documents from Word and WPS indent nearly every paragraph in character units ("two characters" first-line indents), so they opened read-only ("character-unit paragraph indents"). A nonzero character-unit value now overrides its twips companion in direct, style and table-style paragraph properties, for layout, the ruler and list geometry; zero leaves the twips value in force, as before. §17.3.1.12 does not define the character unit; Word measures it by the East Asian font size of the Normal style (a full-width character is one em wide), so it is that size in twips (10.5 pt makes 200 hundredths 420 twips) and follows a change to Normal. A `hangingChars` value wins over `firstLineChars`. Untouched `ind` elements are saved as authored. An edited indent is written in twips and its character-unit form is written as `0` (in the paragraph or style that is edited, whenever the paragraph declares one or inherits a nonzero one), so Word, which also prefers character units, reads the same distance; an indent cleared to inherit removes both forms from the style. Gaps: the character pitch of a `linesAndChars`/`snapToChars` document grid is not used; character-unit indents on numbering levels still open read-only. Unit regression `tests/character-unit-indents.test.js` (sizes and layout offsets, untouched attributes, paragraph and style edits including an inherited character-unit indent, reopen); `tests/canvas-indents-tabs.test.js` now expects the inherited hanging characters to apply; the edited export passes `check:conformance`. |
+| Highlight none in styles | ECMA-376 Part 1 §17.3.2.15 `highlight` (`none`); [MS-OI29500] §§2.1.245, 2.1.248, 2.1.260, 2.1.291 | A document default or style declaring `` opened the whole document read-only ("highlighting in document defaults, named styles or numbering definitions"), although it highlights nothing. It is now accepted and kept as authored; any other highlight value in those places still opens read-only. Unit regression `tests/style-highlight-none.test.js` (document defaults, paragraph and character styles; styles part saved byte for byte). |
+| Comment styles for new comments | ECMA-376 Part 1 §17.13.4.2 `comment`, §17.13.4.5 `commentReference`, §17.7.4.17 `style`; built-in style names "annotation text" and "annotation reference" | New comments are written with the paragraph style `CommentText` and a reference run in the character style `CommentReference`, as Word writes them, but a document that had no comments usually defines neither, so the saved file referred to undefined styles. When a comment is added, the styles part now gets Word's definitions of whichever is missing (`annotation text`: based on the default paragraph style, single line spacing, 10 pt; `annotation reference`: based on the default character style, 8 pt; both priority 99, semi-hidden, unhidden when used). Existing definitions are left as they are, unchanged comments do not touch the styles part, and a package without a styles part is not given one. Unit regression `tests/comment-styles.test.js`; the export passes `check:conformance`. |
+| Revised document author in Compare | ECMA-376 Part 2 §11 core properties (the part the package relationship names; `cp:lastModifiedBy`, `dc:creator`) | Compare labels the revised document's changes with its last author. It read `docProps/core.xml` by name, so a package whose core properties part has another name (System.IO.Packaging's `package/services/metadata/core-properties/.psmdcp`) gave no author and the changes fell back to the user's name. It now reads the relationship-named part, as document property fields do. Unit regression in `tests/compare-documents.test.js` (its fixture package now declares the core properties relationship). |
+| More imported list number formats | ECMA-376 Part 1 §17.9.17 `numFmt`, §17.18.59 ST_NumberFormat | Extends "Imported list number formats": `koreanCounting` (Sino-Korean numerals with 십 백 천 만, one dropped before 십, 백 and 천, as in Japanese), `koreanDigital` (digit by digit, 영 for zero), `hebrew1` (Hebrew letter numerals below 1000, with טו and טז for 15 and 16), `hebrew2` (the 22 letters), `arabicAbjad` and `arabicAlpha` (28 letters each, from أ) and `irohaFullWidth` (47 kana) are now numbered and editable. Values past a sequence, and Hebrew numerals from 1000, are written in decimal digits (this editor's approximation). Hebrew and Arabic labels are laid out in the paragraph's direction; right-to-left paragraphs remain unsupported. The rejection fixtures now use `thaiCounting` as their unsupported format. Unit regression in `tests/imported-numbering-formats.test.js`. |
+
+## Word watermarks (VML in headers and footers)
+
+Clauses: ECMA-376 Part 1 §17.3.3.19 `w:pict`; Part 4 §14.1 VML (`v:shape`, `v:shapetype`,
+`v:textpath`, `v:imagedata`, `v:fill`), with the `mso-position-*` style properties Word writes.
+
+Word's Design > Watermark puts a VML shape in a header paragraph: a WordArt text path
+(`PowerPlusWaterMarkObject…`, e.g. "CONFIDENTIAL") or a picture (`WordPictureWatermark…`, washed out
+with `gain`/`blacklevel`), absolutely positioned behind the text. A `w:pict` in a header or footer
+whose only shape is such a watermark no longer makes the document open read-only. It is one retained
+object in its paragraph (one document position, no width in the line): its XML is saved unchanged,
+also when the header's text is edited, and its image part and relationship are kept. It can be deleted
+with the header's text; it cannot be edited or inserted. Design › Watermark treats it as the
+document's watermark, as Word does: the gallery and the Custom Watermark dialog show it (text, font,
+colour, see-through and diagonal or horizontal layout, read as Auto size; a PNG or JPEG picture with its
+washout), choosing a watermark replaces it, and Remove Watermark removes it from every header (the
+headers' own text stays; the picture's part and relationship are left in place, unused). While the
+watermarks are not changed, Word's XML is saved as it was.
+
+Display (an approximation): on each page whose header or footer holds one, the watermark is drawn
+beneath the page's other content, in its box placed by `mso-position-horizontal/vertical`
+(`center`, `left`/`top`, `right`/`bottom`, or `margin-left`/`margin-top` offsets) relative to the
+margins or the page, and turned by its `rotation`. A text watermark is its string in its
+`font-family`, stretched to fill the box (`v:textpath fitshape`), in its `fillcolor` (named or hex) at
+its `v:fill opacity`; a picture watermark (PNG, JPEG or GIF) is drawn at its `gain` as opacity. Gaps:
+other VML in `w:pict` (and any VML in the body) still opens read-only; `blacklevel`, text-path
+adjustments, outlines, `fitpath` and wrapping other than behind the text are not drawn; watermarks are
+not shown in Read and Web views, which show no headers. Fixture: `tests/fixtures/vml-watermarks`
+(independently generated). Tests: `tests/vml-watermarks.test.js`, `tests/browser/vml-watermarks.spec.js`.
diff --git a/src/documents/docs/field-code-coverage.md b/src/documents/docs/field-code-coverage.md
index 269d1d8e3..bacdd0923 100644
--- a/src/documents/docs/field-code-coverage.md
+++ b/src/documents/docs/field-code-coverage.md
@@ -60,7 +60,7 @@ context-specific readers.
| `FORMTEXT` | §17.16.5.22 | No dedicated body instruction handler or retained-code entry; strict editing import rejects it. Read-only recovery can project an available saved result; evaluation remains missing. |
| `GOTOBUTTON` | §17.16.5.23 | Cached result/source retained; no evaluator for this code in the current field refresh path. Implement its instruction, context, switches and result semantics. |
| `GREETINGLINE` | §17.16.5.24 | Mail merge: inserted by the Greeting Line dialog; `\f` format with `_BEFORE_`/`_AFTER_` text and `\e` fallback for empty names are evaluated. `\l` is retained only. |
-| `HYPERLINK` | §17.16.5.25 | Partial parsed clickable destination and retained instruction; combined external address plus bookmark is explicitly rejected. |
+| `HYPERLINK` | §17.16.5.25 | Partial parsed clickable destination and retained instruction; combined external address plus bookmark is explicitly rejected. A file, relative or UNC address is retained as written and shown as a link that is never followed, as for a `w:hyperlink` relationship target; `javascript:`, `vbscript:` and `data:` addresses keep the document read-only. |
| `IF` | §17.16.5.26 | Retained with nested instruction fields (operands such as `{ MERGEFIELD City }`) kept in the envelope; editable. Evaluated in mail merge preview/output: `= <> < <= > >=`, numeric comparison, `*`/`?` wildcards for `=`/`<>`. Not refreshed by Update Fields outside a merge; formula operands are not evaluated. |
| `INCLUDEPICTURE` | §17.16.5.27 | No dedicated body instruction handler or retained-code entry; strict editing import rejects it. Read-only recovery can project an available saved result; evaluation remains missing. |
| `INCLUDETEXT` | §17.16.5.28 | No dedicated body instruction handler or retained-code entry; strict editing import rejects it. Read-only recovery can project an available saved result; evaluation remains missing. |
diff --git a/src/documents/docs/note-property-coverage.md b/src/documents/docs/note-property-coverage.md
index 48edd132f..c8e9882c5 100644
--- a/src/documents/docs/note-property-coverage.md
+++ b/src/documents/docs/note-property-coverage.md
@@ -21,7 +21,10 @@ is not full restart/format/placement support.
## Document footnote settings
-CT_FtnDocProps. No own attributes in the supplied schemas.
+CT_FtnDocProps. No own attributes in the supplied schemas. The Footnote and Endnote
+dialog writes only the properties changed from the document's current settings: an
+unchanged Apply leaves `settings.xml` as it is, and a footnote change adds no `endnotePr`.
+Settings changes are not yet undoable (they are package settings, outside the edit history).
| Property | Current behavior / remaining work |
| --- | --- |
diff --git a/src/documents/public/fixtures/page-number-parts.docx b/src/documents/public/fixtures/page-number-parts.docx
new file mode 100644
index 000000000..81e2548dd
Binary files /dev/null and b/src/documents/public/fixtures/page-number-parts.docx differ
diff --git a/src/documents/src/collab/binding.js b/src/documents/src/collab/binding.js
index a658bc3b2..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');
@@ -143,13 +144,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();
@@ -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/src/collab/client.js b/src/documents/src/collab/client.js
index 78b41a0d4..476e2edac 100644
--- a/src/documents/src/collab/client.js
+++ b/src/documents/src/collab/client.js
@@ -18,9 +18,13 @@ import { colorFor, randomId, PRESENCE_TTL } from './protocol.js';
const OUTBOX_DB = 'documents-collab';
-/** Queued updates per room, in IndexedDB with a memory fallback (private windows, tests). */
+/**
+ * Queued updates per room and account, in IndexedDB with a memory fallback (private windows, tests). `account`: who
+ * made them. Another person signed in on the same computer opening the room must not send them as theirs (or, with
+ * less access, have them refused and thrown away): each account gets back its own changes only.
+ */
export class Outbox {
- constructor(room, session) { this.room = room; this.session = session; this.items = []; this.memory = !globalThis.indexedDB; }
+ constructor(room, session, account = '') { this.room = room; this.session = session; this.account = String(account ?? ''); this.items = []; this.memory = !globalThis.indexedDB; }
async db() {
if (this.memory) return null;
this.opening ||= new Promise((resolve) => {
@@ -44,13 +48,17 @@ export class Outbox {
} catch { resolve(null); }
});
}
- /** Updates left from an earlier visit to this room in this browser (any session). */
+ /**
+ * Updates this account left from an earlier visit to this room in this browser (any session). Those of other
+ * accounts stay for them; those kept before updates named their account belong to nobody that can be told apart,
+ * and are left alone.
+ */
async restore() {
const all = await this.run('readonly', store => store.getAll());
- return (all || []).filter(item => item.room === this.room).sort((a, b) => a.at - b.at || a.seq - b.seq);
+ return (all || []).filter(item => item.room === this.room && typeof item.account === 'string' && item.account === this.account).sort((a, b) => a.at - b.at || a.seq - b.seq);
}
async add(update) {
- const item = { key: `${this.room}:${this.session}:${Date.now()}:${this.items.length}:${Math.random()}`, room: this.room, at: Date.now(), seq: this.items.length, update };
+ const item = { key: `${this.room}:${this.session}:${Date.now()}:${this.items.length}:${Math.random()}`, room: this.room, account: this.account, at: Date.now(), seq: this.items.length, update };
this.items.push(item);
await this.run('readwrite', store => store.put(item));
return item;
@@ -79,7 +87,7 @@ export class CollabClient extends EventTarget {
this.session = randomId(8);
this.color = colorFor(user.id || user.name || this.session);
this.ydoc = new Y.Doc();
- this.outbox = new Outbox(room, this.session);
+ this.outbox = new Outbox(room, this.session, user.id || user.name || '');
this.roster = [];
this.status = 'connecting';
this.role = null;
diff --git a/src/documents/src/collab/live.js b/src/documents/src/collab/live.js
index beddee2c8..83834b8a4 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.
@@ -292,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;
@@ -303,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/collab/shared-doc.js b/src/documents/src/collab/shared-doc.js
index 0de8d1edf..bf3e14753 100644
--- a/src/documents/src/collab/shared-doc.js
+++ b/src/documents/src/collab/shared-doc.js
@@ -35,7 +35,7 @@ export const MARK = '\n';
/** The comments map's marker that the document has a comments part (possibly empty). */
const PRESENT = '$part';
/** Runs the editor never merges with their neighbours (see normalize() in model.js). */
-export const SEPARATE_RUNS = ['image', 'math', 'field', 'noteMark', 'bodySymbol', 'noBreakHyphen', 'breakClear'];
+export const SEPARATE_RUNS = ['image', 'math', 'vml', 'field', 'noteMark', 'bodySymbol', 'noBreakHyphen', 'breakClear'];
const RUN_DEFAULTS = Object.fromEntries(Object.entries(RUN_FORMAT).map(([k, v]) => [k, JSON.stringify(v)]));
const json = value => JSON.stringify(value);
@@ -122,7 +122,7 @@ export function encodeParagraph(p) {
mark[`p:${key}`] = json(p[key]);
}
add(MARK, mark, attrKey(mark), -1);
- return { text, keys, attrs, separate };
+ return { text, keys, attrs, separate, cellId: typeof p.cellId === 'string' || typeof p.cellId === 'number' ? String(p.cellId) : null };
}
/* ---------- Reading the shared body ---------- */
@@ -251,10 +251,32 @@ export function readComments(map) {
return list.sort((a, b) => (a._order ?? 0) - (b._order ?? 0) || String(a.id).localeCompare(String(b.id), undefined, { numeric: true })).map(({ _order, ...c }) => c);
}
+/**
+ * Paragraphs left in a cell that is gone: someone split a paragraph in a cell while someone else deleted its row or
+ * column, so the new paragraph's mark names that cell. Their text is kept: each goes into the cell before it (or,
+ * at the start of a table, the cell after it), or into the body outside a table. Nothing moves in the shared text,
+ * so every position stays the same index there; the next change written gives the mark its new cell.
+ */
+function adoptOrphanedCellParagraphs(paragraphs, tables) {
+ const live = new Set(tables.flatMap(table => (table.rows || []).flatMap(row => (row.cells || []).map(cell => cell.id))));
+ if (!paragraphs.some(p => p.cellId != null && !live.has(p.cellId))) return;
+ const adopt = (p, from) => { p.cellId = from.cellId; if (from.tableStyleId !== undefined) p.tableStyleId = from.tableStyleId; else delete p.tableStyleId; };
+ const orphaned = paragraphs.map(p => p.cellId != null && !live.has(p.cellId));
+ for (let i = 0; i < paragraphs.length; i++) if (orphaned[i] && i > 0 && paragraphs[i - 1].cellId != null && live.has(paragraphs[i - 1].cellId)) adopt(paragraphs[i], paragraphs[i - 1]);
+ for (let i = paragraphs.length - 1; i >= 0; i--) {
+ const p = paragraphs[i];
+ if (!orphaned[i] || live.has(p.cellId)) continue;
+ const next = paragraphs[i + 1];
+ if (next?.cellId != null && live.has(next.cellId)) adopt(p, next);
+ else { delete p.cellId; delete p.tableStyleId; }
+ }
+}
+
/** The whole model state the shared document holds. */
export function readState(ydoc) {
const paragraphs = readBody(ydoc.getText('body'));
const parts = readParts(ydoc.getMap('parts'), paragraphs);
+ adoptOrphanedCellParagraphs(paragraphs, parts.tables);
// A document without a comments part reads as null until someone comments (the part is then created).
const map = ydoc.getMap('comments');
const comments = map.size ? readComments(map) : null;
@@ -311,7 +333,28 @@ export function writeBody(ytext, before, after, cache = null) {
let index = 0;
for (let i = 0; i < head; i++) index += old[i].encoding.text.length;
const a = concatEncodings(old.slice(head, tailOld)), b = concatEncodings(next.slice(head, tailNew));
- const script = diffText(a.text, b.text);
+ // A table cell's text and paragraph marks are its own. Matched by character alone, a row or column added or removed
+ // kept marks of other cells and relabelled them, and kept letters of a removed cell for those of the next ("c3" and
+ // "c4" share a "c"): someone else's typing anchored there went into another cell, or left a cell's paragraphs apart.
+ // A cell's characters match only the same characters of the same cell; the body's still match each other, so
+ // splitting and joining paragraphs there merge as text does.
+ const cellNumbers = new Map();
+ const symbols = (paragraphs, length) => {
+ const out = new Float64Array(length);
+ let at = 0;
+ for (const { encoding } of paragraphs) {
+ const { text, cellId } = encoding;
+ if (cellId != null) {
+ if (!cellNumbers.has(cellId)) cellNumbers.set(cellId, cellNumbers.size + 1);
+ const base = cellNumbers.get(cellId) * 0x110000;
+ for (let i = 0; i < text.length; i++) out[at + i] = base + text.codePointAt(i);
+ }
+ at += text.length;
+ }
+ return out;
+ };
+ const symbolsA = symbols(old.slice(head, tailOld), a.text.length), symbolsB = symbols(next.slice(head, tailNew), b.text.length);
+ const script = keepCellsLastMarks(diffText(a.text, b.text, { symbolsA, symbolsB }), a.text, symbolsA, symbolsB);
let ai = 0, bi = 0, pos = index;
const uniques = new Map();
const uniqueFor = tag => { if (!uniques.has(tag)) uniques.set(tag, freshUnique(ydoc)); return uniques.get(tag); };
@@ -352,6 +395,32 @@ export function writeBody(ytext, before, after, cache = null) {
return true;
}
+/**
+ * New paragraphs in a cell go before its last mark, not after it: a cell's end is where someone else's new row or
+ * column goes, and two insertions at one place are ordered by who made them, which could put the new paragraphs in the
+ * other person's new cell. A script that keeps a cell's mark and inserts paragraphs of that cell after it (equal
+ * "…M", insert "…M") is the same text as one that inserts them before it (equal "…", insert "M…", equal "M").
+ */
+function keepCellsLastMarks(script, textA, symbolsA, symbolsB) {
+ const out = script.map(segment => ({ ...segment }));
+ let ai = 0, bi = 0;
+ for (let k = 0; k < out.length; k++) {
+ const segment = out[k], following = out[k + 1];
+ if (segment.type === 'equal' && following?.type === 'insert') {
+ const symbol = textA[ai + segment.length - 1] === MARK ? symbolsA[ai + segment.length - 1] : 0;
+ if (symbol && symbolsB[bi + segment.length - 1] === symbol && symbolsB[bi + segment.length + following.length - 1] === symbol) {
+ // The kept mark moves to the end of the insertion: equal one less, insert as long, then that mark kept.
+ segment.length -= 1;
+ out.splice(k + 2, 0, { type: 'equal', length: 1 });
+ if (!segment.length) { out.splice(k, 1); k--; continue; }
+ }
+ }
+ if (segment.type !== 'insert') ai += segment.length;
+ if (segment.type !== 'delete') bi += segment.length;
+ }
+ return out.filter(segment => segment.length);
+}
+
function attributeChange(before = {}, after = {}) {
const change = {};
for (const key of Object.keys(before)) if (!(key in after)) change[key] = null;
@@ -375,7 +444,10 @@ export function encodeComments(comments) {
export function writeState(ydoc, state, origin = null, cache = null) {
let changed = false;
ydoc.transact(() => {
- const before = cache?.body ? null : readBody(ydoc.getText('body'));
+ // What the shared text holds, read as the editor reads it (paragraphs of a removed cell in the cell beside them):
+ // a paragraph the editor shows in a cell is the same paragraph in both, not one to delete and write again.
+ let before = null;
+ if (!cache?.body) { before = readBody(ydoc.getText('body')); adoptOrphanedCellParagraphs(before, readParts(ydoc.getMap('parts'), before).tables); }
changed = writeBody(ydoc.getText('body'), before, state.paragraphs, cache) || changed;
changed = writeRegisters(ydoc.getMap('parts'), encodeParts(state)) || changed;
if (state.comments) {
diff --git a/src/documents/src/collab/text-diff.js b/src/documents/src/collab/text-diff.js
index 91506c939..96a75e13f 100644
--- a/src/documents/src/collab/text-diff.js
+++ b/src/documents/src/collab/text-diff.js
@@ -10,10 +10,12 @@
* When the strings differ in more than `limit` places the middle is replaced
* wholesale (one delete and one insert), which is still correct, only coarser.
*/
-export function diffText(a, b, { limit = 2000 } = {}) {
+export function diffText(a, b, { limit = 2000, symbolsA = null, symbolsB = null } = {}) {
// The script works on code points: a boundary between the halves of a
// surrogate pair would make the shared text replace both with U+FFFD.
- const pointsA = codePoints(a), pointsB = codePoints(b);
+ // `symbolsA`/`symbolsB` (per UTF-16 index: a number above U+10FFFF, or 0 for none) tell apart characters that read
+ // alike but are not the same: such a character matches only one with the same symbol.
+ const [pointsA, wideA] = codePoints(a, symbolsA), [pointsB, wideB] = codePoints(b, symbolsB);
let start = 0;
const shortest = Math.min(pointsA.length, pointsB.length);
while (start < shortest && pointsA[start] === pointsB[start]) start++;
@@ -22,10 +24,10 @@ export function diffText(a, b, { limit = 2000 } = {}) {
const out = [];
let atA = 0, atB = 0;
// Segment lengths count UTF-16 code units, as callers index the strings.
- const units = (points, from, count) => { let n = 0; for (let i = from; i < from + count; i++) n += points[i] > 0xffff ? 2 : 1; return n; };
+ const units = (wide, from, count) => { let n = 0; for (let i = from; i < from + count; i++) n += wide[i] ? 2 : 1; return n; };
const push = (type, count) => {
if (!count) return;
- const length = units(type === 'insert' ? pointsB : pointsA, type === 'insert' ? atB : atA, count);
+ const length = units(type === 'insert' ? wideB : wideA, type === 'insert' ? atB : atA, count);
if (type !== 'insert') atA += count;
if (type !== 'delete') atB += count;
const last = out.at(-1);
@@ -38,15 +40,16 @@ export function diffText(a, b, { limit = 2000 } = {}) {
return out;
}
-function codePoints(text) {
- const points = new Uint32Array(text.length);
+function codePoints(text, symbols = null) {
+ const points = new Float64Array(text.length), wide = new Uint8Array(text.length);
let n = 0;
for (let i = 0; i < text.length; i++) {
const code = text.codePointAt(i);
- points[n++] = code;
+ wide[n] = code > 0xffff ? 1 : 0;
+ points[n++] = symbols?.[i] || code;
if (code > 0xffff) i++;
}
- return points.subarray(0, n);
+ return [points.subarray(0, n), wide.subarray(0, n)];
}
function middleDiff(a, b, limit) {
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/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/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/src/engine/bibliography-fields.js b/src/documents/src/engine/bibliography-fields.js
index 4de69d042..85ae1f244 100644
--- a/src/documents/src/engine/bibliography-fields.js
+++ b/src/documents/src/engine/bibliography-fields.js
@@ -145,9 +145,16 @@ export function updateCitations(doc, sources, style) {
/** Insert a citation of `citation` ({ tags, pages, … }) at the caret. */
export function insertCitation(doc, citation, sources, style) {
const field = newBodyField(citationInstruction({ lcid: '1033', ...citation }));
- // A citation typed straight after a word gets a space before it, as one would type it.
- const at = doc.range[0];
- if (/[\p{L}\p{N}]/u.test(doc.text[at - 1] || '')) { doc.select(at, doc.range[1]); doc.replaceUntracked(' ', { checkpoint: false }); }
+ // With the caret inside a field's result (another citation), the citation goes after that field: inside, it
+ // nested in the result, and the next refresh of the outer citation deleted it.
+ let at = doc.range[0], moved = false;
+ if (at === doc.range[1]) {
+ const around = bodyFieldRanges(doc.paragraphs).filter(r => r.start < at && at < r.end);
+ if (around.length) { at = Math.max(...around.map(r => r.end)); doc.select(at); moved = true; }
+ }
+ // A citation typed straight after a word (or placed after another citation) gets a space before it, as one would type it.
+ const before = doc.text[at - 1] || '';
+ if (moved ? before && !/\s/u.test(before) : /[\p{L}\p{N}]/u.test(before)) { doc.select(at, doc.range[1]); doc.replaceUntracked(' ', { checkpoint: false }); }
const context = citationContext(style, sources, [...citedTags(doc.paragraphs), ...citation.tags]);
const range = writeInlineField(doc, { field, runs: citationRuns(parseCitation(field.instruction), sources, style, context) });
// IEEE numbers follow citation order, so earlier numbers can shift.
diff --git a/src/documents/src/engine/bookmarks.js b/src/documents/src/engine/bookmarks.js
index 19d285441..ea61041bc 100644
--- a/src/documents/src/engine/bookmarks.js
+++ b/src/documents/src/engine/bookmarks.js
@@ -49,7 +49,9 @@ export function bookmarkEntries(paragraphs, tables, onInvalid) {
}
export function checkedBookmarkName(name) {
- if (typeof name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]{0,39}$/.test(name)) throw new Error('Use 1–40 letters, numbers or underscores, starting with a letter or underscore.');
+ // Letters and digits of any script (Закладка, Résumé, 書籤) as word processors accept them, with their combining
+ // marks; no spaces or punctuation; at most 40 characters, counted as the format's readers count them.
+ if (typeof name !== 'string' || !/^[\p{L}_][\p{L}\p{M}\p{N}_]*$/u.test(name) || name.length > 40) throw new Error('Use 1–40 letters, numbers or underscores, starting with a letter or underscore.');
return name;
}
diff --git a/src/documents/src/engine/canvas-backing.js b/src/documents/src/engine/canvas-backing.js
new file mode 100644
index 000000000..51fff1b0e
--- /dev/null
+++ b/src/documents/src/engine/canvas-backing.js
@@ -0,0 +1,16 @@
+// The backing store of a page canvas. Safari on iOS and iPadOS draws nothing in a canvas of more
+// than 4096 × 4096 pixels (16,777,216), and other browsers have larger limits. A page zoomed in on
+// a high-density display can exceed it (a Letter page at 200% on a 3x phone is 4896 × 6336), so
+// such a page is given fewer device pixels per CSS pixel, the same factor on both sides, rather
+// than going blank. Painting scales by the canvas's own width (canvas-editor.js); pointer and
+// caret geometry use CSS pixels, so neither depends on this resolution.
+export const MAX_CANVAS_PIXELS = 4096 * 4096;
+
+/** Device pixels for a page shown at cssWidth × cssHeight CSS pixels on a display of `ratio`. */
+export function pageBackingSize(cssWidth, cssHeight, ratio, maxPixels = MAX_CANVAS_PIXELS) {
+ let width = Math.round(cssWidth * ratio), height = Math.round(cssHeight * ratio);
+ if (width * height <= maxPixels) return { width, height };
+ const scale = Math.sqrt(maxPixels / (cssWidth * cssHeight));
+ width = Math.max(1, Math.floor(cssWidth * scale)); height = Math.max(1, Math.floor(cssHeight * scale));
+ return { width, height };
+}
diff --git a/src/documents/src/engine/canvas-dark-page.js b/src/documents/src/engine/canvas-dark-page.js
new file mode 100644
index 000000000..ed4c40987
--- /dev/null
+++ b/src/documents/src/engine/canvas-dark-page.js
@@ -0,0 +1,103 @@
+/**
+ * Dark page: the page drawn on screen as dark paper with light ink, for the dark appearance's "Dark page"
+ * preference. Nothing in the document changes and nothing is laid out again: a page's context, while its
+ * paint is dark, turns every color it is given (fills, strokes, shadows and gradient stops) as an inverted
+ * page would show it, white paper to near-black and black ink to near-white, keeping hues; pictures and
+ * other drawn bitmaps are left as they are, as a dark reading mode leaves photographs. Printing, PDF and
+ * the saved file paint on their own canvases, which are never dark.
+ */
+
+// How far colors are turned: white paper becomes #1C1C1C and black ink #E3E3E3, about 13:1.
+const AMOUNT = 0.89;
+const DARK = Symbol('dark page');
+const STATE = Symbol('dark page state');
+const COLOR_PROPERTIES = ['fillStyle', 'strokeStyle', 'shadowColor'];
+const GRADIENTS = ['createLinearGradient', 'createRadialGradient', 'createConicGradient'];
+
+/** One channel inverted by AMOUNT, then the color turned half way round the hue circle, as the CSS filters
+ * invert(0.89) hue-rotate(180deg) do: grays and the lightness of colors flip, red stays red. */
+export function darkPageRgb([r, g, b]) {
+ const inv = c => c * (1 - AMOUNT) + (255 - c) * AMOUNT;
+ const [ir, ig, ib] = [inv(r), inv(g), inv(b)];
+ const clamp = v => Math.max(0, Math.min(255, Math.round(v)));
+ return [
+ clamp(-0.574 * ir + 1.43 * ig + 0.144 * ib),
+ clamp(0.426 * ir + 0.43 * ig + 0.144 * ib),
+ clamp(0.426 * ir + 1.43 * ig - 0.856 * ib),
+ ];
+}
+
+const hex = n => n.toString(16).padStart(2, '0');
+/** A canvas color string as a dark page shows it. Colors the canvas cannot read pass through unchanged. */
+export function darkPageColor(value, parse = parseColor) {
+ const rgba = parse(value);
+ if (!rgba) return value;
+ const [r, g, b] = darkPageRgb(rgba);
+ return rgba[3] >= 1 ? `#${hex(r)}${hex(g)}${hex(b)}` : `rgba(${r}, ${g}, ${b}, ${rgba[3]})`;
+}
+
+let scratch = null;
+/** [r, g, b, alpha] for a CSS color, read through a canvas so every color syntax it takes is understood. */
+function parseColor(value) {
+ if (typeof value !== 'string') return null;
+ scratch ??= typeof document === 'undefined' ? null : document.createElement('canvas').getContext('2d');
+ if (!scratch) return null;
+ // A value the canvas does not take leaves the color set before it, an unlikely one, in place.
+ scratch.fillStyle = '#010203';
+ scratch.fillStyle = value;
+ const read = scratch.fillStyle;
+ if (read === '#010203') return null;
+ const short = /^#([0-9a-f]{6})$/i.exec(read);
+ if (short) return [0, 2, 4].map(i => parseInt(short[1].slice(i, i + 2), 16)).concat(1);
+ const long = /^rgba?\(([^)]+)\)$/i.exec(read);
+ if (!long) return null;
+ const parts = long[1].split(',').map(Number);
+ return parts.length >= 3 && parts.every(Number.isFinite) ? [parts[0], parts[1], parts[2], parts.length > 3 ? parts[3] : 1] : null;
+}
+
+const turned = new Map();
+function turn(value) {
+ if (typeof value !== 'string') return value;
+ let out = turned.get(value);
+ if (out === undefined) { out = darkPageColor(value); if (turned.size > 4096) turned.clear(); turned.set(value, out); }
+ return out;
+}
+
+/**
+ * Whether this context paints dark from now on. The first time it is asked to, the context learns to turn its
+ * colors: reading a color back gives the color as it was set, so code that saves and restores one is unaffected.
+ */
+export function setDarkPage(ctx, on) {
+ if (!ctx || (!on && !ctx[STATE])) return;
+ if (!ctx[STATE] && !install(ctx)) return;
+ ctx[DARK] = !!on;
+}
+
+function install(ctx) {
+ const proto = Object.getPrototypeOf(ctx);
+ const descriptors = COLOR_PROPERTIES.map(name => [name, Object.getOwnPropertyDescriptor(proto, name)]);
+ if (descriptors.some(([, d]) => !d?.set || !d?.get)) return false;
+ const state = ctx[STATE] = { set: {}, stack: [] };
+ for (const [name, d] of descriptors) {
+ Object.defineProperty(ctx, name, {
+ configurable: true,
+ get() { return Object.hasOwn(state.set, name) ? state.set[name] : d.get.call(this); },
+ set(value) { state.set[name] = value; d.set.call(this, this[DARK] ? turn(value) : value); },
+ });
+ }
+ const call = name => proto[name];
+ const save = call('save'), restore = call('restore'), reset = call('reset');
+ ctx.save = function () { state.stack.push({ ...state.set }); return save.call(this); };
+ ctx.restore = function () { if (state.stack.length) state.set = state.stack.pop(); return restore.call(this); };
+ if (reset) ctx.reset = function () { state.set = {}; state.stack = []; return reset.call(this); };
+ for (const name of GRADIENTS) {
+ const make = call(name);
+ if (!make) continue;
+ ctx[name] = function (...args) {
+ const gradient = make.apply(this, args);
+ if (this[DARK]) { const add = gradient.addColorStop; gradient.addColorStop = (offset, color) => add.call(gradient, offset, turn(color)); }
+ return gradient;
+ };
+ }
+ return true;
+}
diff --git a/src/documents/src/engine/canvas-editor.js b/src/documents/src/engine/canvas-editor.js
index 65baa9aab..dc8a2df30 100644
--- a/src/documents/src/engine/canvas-editor.js
+++ b/src/documents/src/engine/canvas-editor.js
@@ -1,4 +1,5 @@
import { isNoteStoryKind } from './footnotes.js';
+import { pageBackingSize } from './canvas-backing.js';
import { StorySession } from './story-session.js';
import { decodeShape } from './canvas-shapes.js';
import { wordPosition, optionWordPosition } from './word-navigation.js';
@@ -7,6 +8,7 @@ import { xmlAttributeText } from './text-limits.js';
import { textEdit, followEdit } from './text-edit.js';
import { showLink } from './link-appearance.js';
import { revisionView } from './revisions.js';
+import { hiddenStretchAt, pastHiddenText, outsideHiddenText } from './hidden-text.js';
export const PASTED_PICTURE = /^image\/(png|jpeg|tiff)$/i;
const MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent || '');
@@ -18,6 +20,7 @@ import { cellTextLocalPoint } from './cell-text-direction.js';
import { pageStorySelectionLayout } from './page-story-positions.js';
import { pageStoryAtPoint } from './page-story-hit.js';
import { paintImage } from './paint-image.js';
+import { paintWatermark } from './canvas-watermarks.js';
import { prepareText3dFonts } from './text-3d-fonts.js';
import { localFontVersion } from './local-fonts.js';
import { createCanvasMeasurer } from './canvas-measurer.js';
@@ -44,13 +47,15 @@ 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';
import { lineNumberLabels } from './line-numbers.js';
import { paintPagePictureBackground } from './canvas-page-background.js';
+import { setDarkPage } from './canvas-dark-page.js';
import { viewGeometry, viewPageStories, fitContinuousPages, isContinuousView, usesScreenGeometry } from './document-views.js';
+import { pageLookup } from './page-lookup.js';
import { font, layoutDocument, caretRect, lineAtPosition, verticalLine, hitTestStop, nearestStop, imageAtPoint, selectionRects, selectionRectFinder } from './layout.js';
import { refreshPageReferences } from './page-reference-refresh.js';
import { positionedImageBounds } from './positioned-images.js';
@@ -89,6 +94,7 @@ export class CanvasEditor {
this.readOnly = readOnly;
this.protectedPageFields = protectedPageFields;
this.session = new StorySession(new TextDocument(paragraphs, pageSettings, numbering, styles, tables, assets, { displayOnly: readOnly, comments, trackChanges }), pageStories);
+ this.session.caret = () => ({ affinity: this.caretAffinity, page: this.caretPage });
this.storyPage = 0;
this.images = images;
this.borrowedImages = new Set(borrowImages ? images.values() : []);
@@ -179,7 +185,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
@@ -189,10 +195,18 @@ export class CanvasEditor {
listen(this.input, 'blur', () => { this.finishComposition(); this.paint(); });
listen(this.input, 'copy', e => this.copy(e, false));
listen(this.input, 'cut', e => this.copy(e, true));
+ // A later key press starts a new action: a paste after it is not the one a plain paste's clipboard read stood in for.
+ listen(this.input, 'keydown', () => { this.plainPasteReadAt = 0; });
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;
+ // The browser's paste arriving after the host read the clipboard for the same chord: already pasted.
+ const read = this.plainPasteReadAt; this.plainPasteReadAt = 0;
+ if (read && performance.now() - read < 1000) { e.preventDefault(); return; }
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; }
@@ -611,8 +625,10 @@ export class CanvasEditor {
// The host can keep history beyond this editor's (changes that reopened the package).
if (!this.session[direction === 'undo' ? 'history' : 'future'].length && this.onHistoryEnd?.(direction)) return;
try {
- // The caret moves to the restored selection: Up and Down start from its column there, not the one before.
- if (this.session[direction](() => { this.validateStory(); this.preferredX = null; this.caretAffinity = 'forward'; this.relayout(); })) this.onChange();
+ // The caret moves to the restored selection, on the side of a line's end it was on there: Up and Down start
+ // from its column there, not the one before.
+ const caret = this.session[direction === 'undo' ? 'history' : 'future'].at(-1)?.caret;
+ if (this.session[direction](() => { this.validateStory(); this.preferredX = null; this.caretAffinity = caret?.affinity || 'forward'; this.caretPage = caret ? caret.page : this.caretPage; this.relayout(); })) this.onChange();
this.focus(); this.revealCaret();
} catch (e) { this.relayout(); this.onError(e); }
}
@@ -753,7 +769,8 @@ export class CanvasEditor {
// CSS pixels, so a fractional page size (A4, fit-width and slider zooms, or any odd number of
// device pixels on a 2x display) would otherwise be resampled, which blurs every page.
const cssWidth = Math.max(1, Math.round(geometry.width * this.zoom)), cssHeight = Math.max(1, Math.round(geometry.height * this.zoom));
- const width = Math.round(cssWidth * ratio), height = Math.round(cssHeight * ratio);
+ // A page too large for the browser's canvas limit gets fewer device pixels (canvas-backing.js).
+ const { width, height } = pageBackingSize(cssWidth, cssHeight, ratio);
// Assigning a dimension clears and reallocates the canvas even when unchanged, and pages
// off screen are repainted only later: keep the backing store of pages that kept their size.
if (canvas.width !== width) canvas.width = width;
@@ -794,7 +811,7 @@ export class CanvasEditor {
const geometry = this.layout.pages[index], canvas = document.createElement('canvas');
canvas.className = 'document-page';
canvas.style.width = `${geometry.width}px`; canvas.style.height = `${geometry.height}px`;
- canvas.width = Math.round(geometry.width * 2); canvas.height = Math.round(geometry.height * 2);
+ ({ width: canvas.width, height: canvas.height } = pageBackingSize(geometry.width, geometry.height, 2));
sheets[index] = canvas;
}
this.paint({ canvases: sheets, printing: true, pages: chosen });
@@ -848,6 +865,12 @@ export class CanvasEditor {
};
this.paintTask = idle ? { idle, id: requestIdleCallback(run, { timeout: 250 }) } : { idle, id: setTimeout(run, 16) };
}
+ /** Screen pages as dark paper with light ink (the dark appearance's Dark page preference); printing is unaffected. */
+ setDarkPage(on) {
+ if (!!this.darkPage === !!on) return;
+ this.darkPage = !!on;
+ this.paint();
+ }
paint({ canvases = this.canvases, printing = false, pages = null } = {}) {
if (this.destroyed || !this.layout) return;
let indices = null;
@@ -878,6 +901,8 @@ export class CanvasEditor {
const canvas = canvases[index], ctx = canvas.getContext('2d'), geometry = this.layout.pages[index] || this.layout.page;
// A canvas keeps its pixels and drawing state between paints (resizeCanvases keeps unchanged sizes).
ctx.reset?.();
+ // Dark page turns the screen's colors only; a page with a background picture keeps the picture's own light.
+ setDarkPage(ctx, !printing && !!this.darkPage && !geometry.backgroundPicture);
setCanvasFontAlternatives(ctx, this.model.styles.fontAlternatives);
const scale = canvas.width / geometry.width;
ctx.setTransform(scale, 0, 0, scale, 0, 0);
@@ -903,6 +928,8 @@ export class CanvasEditor {
// Fade the header and footer before the body paints, so body text and drawings that reach
// into the margins keep their own colours.
if (!pageStory && storyChrome?.active === 'body') paintStoryChrome(ctx, geometry, storyChrome.bounds, 'body');
+ // A header's or footer's Word watermark lies beneath everything else on the page.
+ if (pageStory) for (const w of this.storyLayout.watermarks || []) if (w.page === index) paintWatermark(ctx, w, this.images);
const floatingObjects = pageStory ? this.storyLayout.floatingObjects : this.layout.floatingObjects || [];
const paintFloating = behind => {
// A hidden drawing (§20.4.2.5) keeps its anchor and place but paints nothing; hidden ink likewise.
@@ -1137,7 +1164,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);
}
@@ -1168,12 +1195,22 @@ export class CanvasEditor {
this.focus();
}
removeHyperlink() { this.execute(() => this.model.removeHyperlink()); this.focus(); }
- get bookmarks() { return this.model.bookmarks.map(b => ({ id: b.id, name: b.name, start: b.start, end: b.end, page: lineAtPosition(this.selectionLayout, b.columnRange?.target ?? b.start, 'forward').page + 1 })); }
+ /** The body's bookmarks (headers, footers and notes hold none), each with the page it is on as that page is numbered. */
+ get bookmarks() {
+ const body = this.session.body, printed = pageLookup(this.layout, this.pageStories);
+ return body.bookmarks.map(b => {
+ const at = b.columnRange?.target ?? b.start, line = lineAtPosition(this.layout, at, 'forward');
+ return { id: b.id, name: b.name, start: b.start, end: b.end, page: printed(at, line.paragraphId)?.text ?? String(line.page + 1) };
+ });
+ }
addBookmark(name) { return this.execute(() => this.model.addBookmark(name)); }
- renameBookmark(id, name) { return this.execute(() => this.model.renameBookmark(id, name)); }
- removeBookmark(id) { return this.execute(() => this.model.removeBookmark(id)); }
+ // A bookmark listed from a header or footer is the body's: renaming or deleting it works there.
+ renameBookmark(id, name) { if (this.session.path !== null) this.editStory(null); return this.execute(() => this.model.renameBookmark(id, name)); }
+ removeBookmark(id) { if (this.session.path !== null) this.editStory(null); 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;
@@ -1191,7 +1228,8 @@ export class CanvasEditor {
this.pointImage = imageAtPoint(this.selectionLayout, page, (event.clientX - nearest.r.left) / this.zoom, y, { includeBehindText: !!event.altKey });
// A view can hide some drawings (hidden ink); they are not painted, so they are not hit either.
if (this.pointImage && this.hiddenDrawing?.(this.pointImage.image)) this.pointImage = null;
- const hit = hitTestStop(this.selectionLayout, page, (event.clientX - nearest.r.left) / this.zoom, y), position = hit.offset;
+ // A stop inside hidden text shows where that text is passed over.
+ const hit = hitTestStop(this.selectionLayout, page, (event.clientX - nearest.r.left) / this.zoom, y), position = updateCaret ? outsideHiddenText(this.model.paragraphs, hit.offset) : hit.offset;
this.pointLink = hyperlinkAtPoint(this.selectionLayout, this.model.paragraphs, page, (event.clientX - nearest.r.left) / this.zoom, y, this.pointImage);
if (!updateCaret) return position;
this.caretPage = page;
@@ -1479,6 +1517,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;
@@ -1490,7 +1555,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);
@@ -1560,16 +1625,19 @@ export class CanvasEditor {
}
else if (key === 'ArrowLeft' || key === 'ArrowRight') {
const direction = key === 'ArrowLeft' ? -1 : 1;
- if (nativeWord) target = wordPosition(this.model.text, focus, direction);
- else if (word) target = optionWordPosition(this.model.text, focus, direction);
- else {
- // The caret stops are in order: find the neighbours by halving, not by reading a long document's every stop.
- const stops = boundaries(this.model.text);
- let low = 0, high = stops.length;
- while (low < high) { const middle = (low + high) >> 1; if (stops[middle] < focus) low = middle + 1; else high = middle; }
- target = direction < 0 ? stops[low - 1] ?? 0 : stops[stops[low] === focus ? low + 1 : low] ?? this.model.text.length;
- }
- target = this.model.promptBoundary(target,direction);
+ target = pastHiddenText(this.model.paragraphs, focus, direction, from => {
+ let next;
+ if (nativeWord) next = wordPosition(this.model.text, from, direction);
+ else if (word) next = optionWordPosition(this.model.text, from, direction);
+ else {
+ // The caret stops are in order: find the neighbours by halving, not by reading a long document's every stop.
+ const stops = boundaries(this.model.text);
+ let low = 0, high = stops.length;
+ while (low < high) { const middle = (low + high) >> 1; if (stops[middle] < from) low = middle + 1; else high = middle; }
+ next = direction < 0 ? stops[low - 1] ?? 0 : stops[stops[low] === from ? low + 1 : low] ?? this.model.text.length;
+ }
+ return this.model.promptBoundary(next, direction);
+ });
this.preferredX = null;
} else {
const line = currentLine || lineAtPosition(this.selectionLayout, focus, this.caretAffinity, this.caretPage);
@@ -1589,6 +1657,8 @@ export class CanvasEditor {
this.caretPage = next?.page ?? null;
}
}
+ // Up, Down, Home, End and the page keys can reach a stop inside hidden text; the caret shows past it.
+ if (!['ArrowLeft', 'ArrowRight'].includes(key)) target = outsideHiddenText(this.model.paragraphs, target);
this.caretAffinity = affinity;
if (mod && ['Home', 'End'].includes(key)) this.caretPage = null;
let controlMove = null;
@@ -1699,7 +1769,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);
@@ -1802,6 +1875,8 @@ export class CanvasEditor {
// Typing continues one undo step until a new word starts.
// Any other change, caret move, undo or story switch ends it.
typeText(action, text) {
+ // Text is not typed into the middle of hidden text, where nobody sees it go.
+ if (this.model.range[0] === this.model.range[1]) { const at = this.model.range[0], shown = outsideHiddenText(this.model.paragraphs, at); if (shown !== at) this.model.select(shown); }
const group = this.typingGroup, [start, end] = this.model.range;
const newWord = /\S/u.test(text[0] || '') && /\s/u.test(group?.last || '');
const merge = !!group && !newWord && start === end && group.at === start &&
@@ -1823,8 +1898,19 @@ 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;
+ // Hidden text beside the caret is passed over: the key deletes the character a reader sees next to it.
+ const stretch = start === end && hiddenStretchAt(this.model.paragraphs, start);
+ const past = stretch && (direction < 0 ? stretch[0] < start : start < stretch[1]) ? stretch[direction < 0 ? 0 : 1] : null;
+ try {
+ done = this.execute(() => {
+ if (past !== null) this.model.select(past);
+ return 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.
@@ -1947,7 +2033,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 {
@@ -1963,6 +2050,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/src/engine/canvas-text.js b/src/documents/src/engine/canvas-text.js
index 548602c4c..ed1725595 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,11 @@ 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, and distributed paragraphs, widen the gaps between characters with
+ // letter spacing (justification.js).
+ if (justified.some(p => p.runs.some(r => IDEOGRAPHIC.test(r.text))) || pkg.paragraphs.some(p => p.format?.alignment === 'distribute')) setCanvasTextStyle(context, { characterSpacing: 1 });
}
function canvasFontRequirements(pkg) {
diff --git a/src/documents/src/engine/canvas-watermarks.js b/src/documents/src/engine/canvas-watermarks.js
new file mode 100644
index 000000000..eb74805da
--- /dev/null
+++ b/src/documents/src/engine/canvas-watermarks.js
@@ -0,0 +1,34 @@
+import { paintDecodedImage } from './images.js';
+
+// Paint Word watermarks (retained VML, see vml-watermarks.js) behind a page's text: a WordArt text
+// path stretched to fill its box (v:textpath fitshape), in its fill colour and opacity, or a picture
+// washed out by its gain. Both turn about the box's centre by the shape's rotation (degrees clockwise).
+export function paintWatermark(ctx, frame, images) {
+ const { watermark: w, x, y, width, height } = frame;
+ if (!(width > 0 && height > 0)) return;
+ ctx.save();
+ ctx.translate(x + width / 2, y + height / 2);
+ if (w.rotation) ctx.rotate(w.rotation * Math.PI / 180);
+ if (w.kind === 'text') {
+ if (!w.color) { ctx.restore(); return; }
+ ctx.globalAlpha *= w.opacity ?? 1;
+ ctx.fillStyle = `#${w.color}`;
+ const size = 100;
+ ctx.font = `${size}px "${String(w.font).replace(/["\\]/g, '')}", sans-serif`;
+ ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
+ const m = ctx.measureText(w.text), ascent = m.actualBoundingBoxAscent || size * 0.72, descent = m.actualBoundingBoxDescent || 0;
+ const left = m.actualBoundingBoxLeft ?? 0, inkWidth = (m.actualBoundingBoxRight ?? m.width) + left || m.width;
+ if (inkWidth > 0 && ascent + descent > 0) {
+ ctx.scale(width / inkWidth, height / (ascent + descent));
+ ctx.fillText(w.text, -inkWidth / 2 + left, (ascent - descent) / 2);
+ }
+ } else if (w.kind === 'picture') {
+ const bitmap = w.assetId && images?.get(w.assetId);
+ if (bitmap) {
+ // Washout (gain below 1) lightens the picture towards the page.
+ ctx.globalAlpha *= w.gain > 0 && w.gain < 1 ? w.gain : 1;
+ paintDecodedImage(ctx, bitmap, -width / 2, -height / 2, width, height);
+ }
+ }
+ ctx.restore();
+}
diff --git a/src/documents/src/engine/captions.js b/src/documents/src/engine/captions.js
index 9b952b503..d331fef52 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) {
@@ -148,6 +149,12 @@ function insertReferenceField(doc, instruction, placeholder, name, hyperlink) {
* relative-position reference after it ("Figure 1 below").
*/
export function insertCrossReference(doc, target, { show = 'text', hyperlink = true, aboveBelow = false } = {}) {
+ // With the caret inside a field's result (another cross-reference), the new one goes after that field rather than
+ // splitting its result ("AlBetapha").
+ if (doc.range[0] === doc.range[1]) {
+ const at = doc.range[0], around = bodyFieldRanges(doc.paragraphs).filter(r => r.start < at && at < r.end);
+ if (around.length) doc.select(Math.max(...around.map(r => r.end)));
+ }
let start = target.start, end = target.end;
if (show === 'entire' && target.paragraphEnd !== undefined) end = target.paragraphEnd;
if (show === 'caption' && target.textStart !== undefined) { start = target.textStart; end = target.paragraphEnd; if (start >= end) throw new Error('This caption has no text after its number.'); }
diff --git a/src/documents/src/engine/clipboard-fragment.js b/src/documents/src/engine/clipboard-fragment.js
index d3f2fd0e6..6d5a42238 100644
--- a/src/documents/src/engine/clipboard-fragment.js
+++ b/src/documents/src/engine/clipboard-fragment.js
@@ -1,4 +1,4 @@
-import { paragraphText, paragraphFormat } from './model.js';
+import { paragraphText, paragraphFormat, locate } from './model.js';
import { runFormat } from './formatting.js';
import { listKind } from './numbering.js';
import { HIGHLIGHTS } from './backgrounds.js';
@@ -17,23 +17,36 @@ export const FRAGMENT_ATTRIBUTE = 'data-documents-fragment';
const RUN_KEYS = ['bold', 'italic', 'underlineStyle', 'strikethrough', 'color', 'highlight', 'fontSize', 'fontAscii', 'verticalAlign', 'capitalization'];
const MAX_RUNS = 2000;
-/** Whether this document's content can be copied or pasted with formatting. */
-export function carriesFormatting(model) {
- return !(model.hasContentControls || model.hasBodyFields || model.hasBlockControls || model.hasNoteMarks || model.hasPlainTextControls || model.hasMacroButtons);
-}
+const plainRun = run => !run.image && !run.math && !run.vml && !run.bodySymbol && !run.noteMark && !run.contentControl && !run.field && !/[\u000e]/.test(run.text);
+// A paragraph a formatted paste can go into: no field, note, content control or object anywhere in it.
+const plainParagraph = p => !p.blockControls?.length && !p.fieldMarkers?.length && p.runs.every(plainRun);
-const plainRun = run => !run.image && !run.math && !run.bodySymbol && !run.noteMark && !run.contentControl && !run.field && !/[\u000e]/.test(run.text);
+/**
+ * Whether a paste at the selection (or `range`) can take formatting. The commands that reapply it work on the
+ * pasted text by offsets, which fields, notes and content controls in the paragraphs it goes into could shift;
+ * those paragraphs take plain text. Elsewhere in a document that has them, formatting is pasted as usual.
+ */
+export function carriesFormatting(model, [start, end] = model.range) {
+ if (!(model.hasContentControls || model.hasBodyFields || model.hasBlockControls || model.hasNoteMarks || model.hasPlainTextControls || model.hasMacroButtons)) return true;
+ const first = locate(model.paragraphs, start).index, last = locate(model.paragraphs, end).index;
+ for (let i = first; i <= last; i++) if (!plainParagraph(model.paragraphs[i])) return false;
+ return true;
+}
/** The selection as paragraphs of formatted runs, or null when it holds more than text. */
export function copyFragment(model) {
const [start, end] = model.range;
- if (start === end || !carriesFormatting(model)) return null;
- const paragraphs = []; let at = 0, runCount = 0;
+ // What the selection holds decides (each run is checked below); fields or notes elsewhere do not.
+ if (start === end) return null;
+ 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);
@@ -89,7 +102,7 @@ export function fragmentHtml(fragment) {
const heading = /^Heading([1-6])$/.exec(p.styleId || '')?.[1] || (p.styleId === 'Title' ? '1' : null);
const tag = kind ? 'li' : heading ? `h${heading}` : 'p';
// Tabs and runs of spaces are the text's own, which HTML collapses into one space unless told otherwise.
- const align = p.alignment && p.alignment !== 'left' ? `;text-align:${p.alignment === 'both' ? 'justify' : p.alignment}` : '';
+ const align = p.alignment && p.alignment !== 'left' ? `;text-align:${p.alignment === 'both' ? 'justify' : p.alignment === 'distribute' ? 'justify;text-align-last:justify' : p.alignment}` : '';
html += `<${tag} style="white-space:pre-wrap${align}">${p.runs.map(runHtml).join('') || '
'}${tag}>`;
}
if (openList) html += `${openList}>`;
@@ -152,6 +165,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' };
@@ -173,8 +188,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';
@@ -199,6 +219,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) => {
@@ -241,7 +262,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];
@@ -257,14 +282,26 @@ 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 };
- 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(/ +$/, '');
+ // 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 } : {}) };
+ }
+ // Every cell after a row's first starts after a tab, an empty one included, so the cells after it stay in their columns.
+ if ((tag === 'TD' || tag === 'TH') && current && /^T[DH]$/.test(node.previousElementSibling?.tagName || '')) {
+ const last = current.runs.at(-1);
+ if (last) { last.text = last.text.replace(/ +$/, ''); current.text = current.text.replace(/ +$/, ''); }
append('\t', format, null);
}
// 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)) {
@@ -273,6 +310,8 @@ function fromForeignHtml(body) {
}
}
if (tag === 'LI') ctx.item = current;
+ // A horizontal rule is an empty paragraph with a line under it, as Word draws one (and makes of "---").
+ if (tag === 'HR' && current) { current.rule = true; current.filled = true; }
for (const child of node.childNodes) walk(child, next, ctx);
if (block) end();
};
@@ -294,11 +333,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));
@@ -320,11 +363,30 @@ 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(''));
if (p.alignment && model.paragraphFormatting.alignment !== p.alignment) attempt(() => model.setParagraphFormat({ alignment: p.alignment }));
+ if (p.rule === true) attempt(() => model.setParagraphFormat({ paragraphBorders: { bottom: { style: 'single', size: 6, space: 1, color: 'auto' } } }));
const current = model.listFormatting;
if (p.list) {
if (current !== p.list.kind) {
@@ -332,6 +394,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));
}
@@ -391,8 +454,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/src/engine/compare.js b/src/documents/src/engine/compare.js
index 69e4ba1ee..bc524e50c 100644
--- a/src/documents/src/engine/compare.js
+++ b/src/documents/src/engine/compare.js
@@ -9,6 +9,7 @@
// Combine: base = original document, other = a revised copy (other-only text is inserted text).
import { trackFormatChanges } from './revisions.js';
import { documentBlocks } from './tables.js';
+import { readCoreProperties } from './docx-core-properties.js';
const OBJECT = '';
@@ -102,7 +103,7 @@ export function diffSequences(a, b, equals = (x, y) => x === y, { maxEdits = 400
const visible = run => run.revision?.type !== 'del';
/** The paragraph's text with tracked deletions left out. */
export const finalText = p => p.runs.filter(visible).map(r => r.text).join('');
-const special = r => r.image || r.math || r.field || r.noteMark || r.bodySymbol || r.noBreakHyphen || r.breakClear || r.contentControl;
+const special = r => r.image || r.math || r.vml || r.field || r.noteMark || r.bodySymbol || r.noBreakHyphen || r.breakClear || r.contentControl;
const fullLength = p => p.runs.reduce((n, r) => n + r.text.length, 0);
/** Similarity of two texts: the Dice coefficient of their character bigrams (case-insensitive). */
export function similarity(x, y) {
@@ -120,7 +121,7 @@ export function similarity(x, y) {
function sanitizedRun(run, styleIds) {
if (special(run) && run.text === OBJECT) return null;
const out = { ...structuredClone(run) };
- for (const key of ['link', 'contentControl', 'formatRevision', 'revision', 'image', 'math', 'field', 'noteMark', 'bodySymbol', 'tocHyperlink']) delete out[key];
+ for (const key of ['link', 'contentControl', 'formatRevision', 'revision', 'image', 'math', 'vml', 'field', 'noteMark', 'bodySymbol', 'tocHyperlink']) delete out[key];
if (out.styleId && !styleIds.has(out.styleId)) {
out.styleId = null;
if (out.properties) out.properties = out.properties.replace(/<(?:\w+:)?rStyle\b[^>]*\/>/g, '');
@@ -419,17 +420,11 @@ export function mergeDocuments(base, other, { mode = 'compare', author = 'Author
return { paragraphs: out, tables: base.tables, stats };
}
-/** The last author recorded in a package's core properties (cp:lastModifiedBy, then dc:creator). */
+/**
+ * The last author recorded in a package's core properties (cp:lastModifiedBy, then dc:creator), read from
+ * the part the package relationship names (ECMA-376 Part 2 §11).
+ */
export function documentAuthor(parts) {
- const core = parts?.['docProps/core.xml'];
- if (!core) return null;
- try {
- const text = typeof core === 'string' ? core : new TextDecoder().decode(core);
- const doc = new DOMParser().parseFromString(text, 'application/xml');
- for (const name of ['lastModifiedBy', 'creator']) {
- const node = [...doc.getElementsByTagName('*')].find(n => n.localName === name);
- if (node?.textContent.trim()) return node.textContent.trim();
- }
- } catch { /* unreadable core properties */ }
- return null;
+ const core = readCoreProperties(parts || {});
+ return core.lastModifiedBy || core.creator || null;
}
diff --git a/src/documents/src/engine/diagram-builder.js b/src/documents/src/engine/diagram-builder.js
index 444ce050f..ec7849eef 100644
--- a/src/documents/src/engine/diagram-builder.js
+++ b/src/documents/src/engine/diagram-builder.js
@@ -69,6 +69,23 @@ function fitSize(text, w, h, max = 18, min = 8) {
}
/** Shape boxes for a layout: [{ x, y, w, h, preset, role: 'node'|'accent'|'connector', text, level, index, rotation, adj }]. */
+/**
+ * The items (by text, in outline order) a layout leaves out of its picture: levels below those it
+ * draws, sub-items of layouts that draw top-level items only, items past the number it has room for.
+ * Read from the shapes the layout makes, so it stays true to each layout.
+ */
+export function hiddenDiagramItems(layoutId, items) {
+ const shown = new Map();
+ for (const shape of diagramShapes(layoutId, items)) for (const line of String(shape.text || '').split('\n')) {
+ const text = line.replace(/^•\s*/, '').trim();
+ if (text) shown.set(text, (shown.get(text) || 0) + 1);
+ }
+ const hidden = [];
+ const walk = list => { for (const item of list) { const count = shown.get(item.text) || 0; if (count) shown.set(item.text, count - 1); else hidden.push(item.text); walk(item.children); } };
+ walk(items);
+ return hidden;
+}
+
export function diagramShapes(layoutId, items, { width = toEmu(6), height = toEmu(3.5) } = {}) {
const top = items.length ? items : [{ text: ' ', children: [] }];
const n = top.length, shapes = [], gap = toEmu(0.12);
diff --git a/src/documents/src/engine/dictionary-breaks.js b/src/documents/src/engine/dictionary-breaks.js
index a171dceb5..e219addcf 100644
--- a/src/documents/src/engine/dictionary-breaks.js
+++ b/src/documents/src/engine/dictionary-breaks.js
@@ -15,10 +15,25 @@ 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;
+// 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;
+// 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) || WORD_JOINER.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 +49,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/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-backgrounds.js b/src/documents/src/engine/docx-backgrounds.js
index a7a4e34e1..ba774a8af 100644
--- a/src/documents/src/engine/docx-backgrounds.js
+++ b/src/documents/src/engine/docx-backgrounds.js
@@ -26,6 +26,8 @@ export function readRunBackground(rPr, readColor) {
// Highlight is not permitted in these contexts ([MS-OI29500] §§2.1.245,
// 2.1.248, 2.1.260, 2.1.291). Keep unused definitions, reject active ones.
+// A highlight of none (§17.3.2.15) highlights nothing and is accepted.
export function rejectStyleHighlight(rPr) {
- if (single(rPr, 'highlight')) fail('highlighting in document defaults, named styles or numbering definitions');
+ const highlight = single(rPr, 'highlight');
+ if (highlight && highlight.getAttributeNS(W, 'val') !== 'none') fail('highlighting in document defaults, named styles or numbering definitions');
}
diff --git a/src/documents/src/engine/docx-body-fields.js b/src/documents/src/engine/docx-body-fields.js
index e3d94e744..dee02a61d 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 doc.createElementNS(W, `w:${name}`);
const used = new Set(comments.map(c => c.paraId).filter(Boolean));
const paraIds = new Map();
+ let authored = false;
for (const comment of comments) {
let node;
if (unchanged(comment) && sources.has(comment.sourceId)) {
@@ -138,6 +139,7 @@ export function writeComments(parts, comments = [], initial = '[]') {
if (comment[key]) node.setAttributeNS(W, `w:${attr}`, comment[key]); else node.removeAttributeNS(W, attr);
}
} else {
+ authored = true;
node = make('comment');
node.setAttributeNS(W, 'w:author', comment.author || 'Author');
if (comment.date) node.setAttributeNS(W, 'w:date', comment.date);
@@ -175,6 +177,31 @@ export function writeComments(parts, comments = [], initial = '[]') {
write(parts, path, doc);
ensureRelationship(parts, path, COMMENTS_REL, COMMENTS_TYPE);
writeCompanions(parts, comments, paraIds);
+ if (authored) ensureCommentStyles(parts);
+}
+
+/**
+ * New comments use the paragraph style CommentText and the character style CommentReference (Word's
+ * built-in "annotation text" and "annotation reference"). Add Word's definitions of the ones a styles
+ * part lacks; existing definitions are left as they are. A package without a styles part keeps none.
+ */
+function ensureCommentStyles(parts) {
+ let path = null;
+ try { path = relatedPartPath(parts, 'styles'); } catch { path = null; }
+ if (!path || !parts[path]) return;
+ const doc = parseXml(parts[path]), root = doc.documentElement;
+ if (root.namespaceURI !== W || root.localName !== 'styles') return;
+ const styles = wChildren(root, 'style'), has = (type, id) => styles.some(s => s.getAttributeNS(W, 'type') === type && s.getAttributeNS(W, 'styleId') === id);
+ const defaultId = type => styles.find(s => s.getAttributeNS(W, 'type') === type && ['1', 'true', 'on'].includes(s.getAttributeNS(W, 'default')))?.getAttributeNS(W, 'styleId');
+ const missing = [];
+ if (!has('paragraph', 'CommentText')) missing.push(['paragraph', 'CommentText', 'annotation text', defaultId('paragraph'), '']);
+ if (!has('character', 'CommentReference')) missing.push(['character', 'CommentReference', 'annotation reference', defaultId('character'), '']);
+ if (!missing.length) return;
+ for (const [type, id, name, basedOn, formatting] of missing) {
+ const xml = `${basedOn ? ` ({ '&': '&', '<': '<', '"': '"' })[c])}"/>` : ''}${formatting}`;
+ root.append(doc.importNode(parseXml(xml).documentElement, true));
+ }
+ write(parts, path, doc);
}
function writeCompanions(parts, comments, paraIds) {
diff --git a/src/documents/src/engine/docx-content-controls.js b/src/documents/src/engine/docx-content-controls.js
index 2b126bae1..7875207f3 100644
--- a/src/documents/src/engine/docx-content-controls.js
+++ b/src/documents/src/engine/docx-content-controls.js
@@ -150,7 +150,10 @@ export function contentControlReader(readRunProperties, parts = {}) {
if (!runs && promptParagraphs.some(p => p.bookmarks.length)) fail('bookmarks in an empty placeholder definition');
return runs ? { runs, bookmarks: promptParagraphs.map(p => p.bookmarks) } : undefined;
};
- return (node, kind = 'inline') => {
+ // `sharedId`: a header's or footer's page-number or watermark building block (docx.js documentPartStory) keeps its
+ // w:id in its retained wrapper and is identified by nothing else, so a repeated id (sibling blocks, or a header
+ // copied into another part) does not stop the story being edited.
+ return (node, kind = 'inline', { sharedId = false } = {}) => {
if (count >= 2000) fail('more than 2,000 controls');
attributes(node);
const elements = children(node), order = ['sdtPr', 'sdtEndPr', 'sdtContent']; let previous = -1;
@@ -185,7 +188,7 @@ export function contentControlReader(readRunProperties, parts = {}) {
if (value === null) fail(`missing ${p.localName} value`);
if (p.localName === 'lock' && value !== 'unlocked') fail('locked contents or wrappers');
if (p.localName === 'id') {
- if (!/^[+-]?\d+$/.test(value) || !Number.isInteger(Number(value)) || Number(value) < -2147483648 || Number(value) > 2147483647 || ids.has(Number(value))) fail('invalid or duplicate identifiers');
+ if (!/^[+-]?\d+$/.test(value) || !Number.isInteger(Number(value)) || Number(value) < -2147483648 || Number(value) > 2147483647 || ids.has(Number(value)) && !sharedId) fail('invalid or duplicate identifiers');
ids.add(Number(value));
}
} else if (p.localName === 'placeholder') {
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/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-numbering.js b/src/documents/src/engine/docx-numbering.js
index 5123408ad..94314def3 100644
--- a/src/documents/src/engine/docx-numbering.js
+++ b/src/documents/src/engine/docx-numbering.js
@@ -2,7 +2,7 @@ import { readUnderline } from './docx-underlines.js';
import { underlinePattern } from './underlines.js';
import { checkedCapitalization } from './capitalization.js';
import { readCapitalization } from './docx-capitalization.js';
-import { checkedStrike } from './strikethrough.js';
+import { checkedStrike, strikeDeclarations } from './strikethrough.js';
import { readStrike } from './docx-strikethrough.js';
import { readKerning } from './docx-kerning.js';
import { checkedKerning } from './kerning.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';
@@ -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];
@@ -73,7 +80,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';
@@ -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;
@@ -340,7 +348,7 @@ export function writeNumbering(parts, numbering) {
for (const [key, tag] of [['bold', 'b'], ['italic', 'i'], ['capitalization', 'caps'], ['strikethrough', 'strike'], ['color', 'color'], ['characterSpacing', 'spacing'], ['textScale', 'w'], ['kerning', 'kern'], ['baselineShift', 'position'], ['fontSize', 'sz'], ['underline', 'u'], ['shading', 'shd'], ['verticalAlign', 'vertAlign']]) if (key in level.markerStyle || key === 'underline' && ('underlineStyle' in level.markerStyle || 'underlineColor' in level.markerStyle)) {
const v = level.markerStyle[key];
if (key === 'capitalization') { checkedCapitalization(v, true); rPr.append(make(v.startsWith('smallCaps') ? 'smallCaps' : 'caps', ['caps', 'smallCaps'].includes(v) ? '1' : '0')); continue; }
- if (key === 'strikethrough') { checkedStrike(v, true); rPr.append(make(v.startsWith('double') ? 'dstrike' : 'strike', ['single', 'double'].includes(v) ? '1' : '0')); continue; }
+ if (key === 'strikethrough') { checkedStrike(v, true); for (const value of strikeDeclarations(v)) rPr.append(make(value.startsWith('double') ? 'dstrike' : 'strike', ['single', 'double'].includes(value) ? '1' : '0')); continue; }
if (key === 'underline') { const node = make('u'); if ('underline' in level.markerStyle || 'underlineStyle' in level.markerStyle) node.setAttributeNS(W, 'w:val', underlinePattern(level.markerStyle)); rPr.append(node); continue; }
rPr.append(make(tag, key === 'shading' ? v === 'foreground' ? 'solid' : 'clear' : ['color', 'verticalAlign', 'textScale'].includes(key) ? v : key === 'characterSpacing' ? Math.round(v * 20) : ['fontSize', 'baselineShift', 'kerning'].includes(key) ? v * 2 : v ? '1' : '0'));
}
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/src/engine/docx-strikethrough.js b/src/documents/src/engine/docx-strikethrough.js
index b16a4f104..40a363ceb 100644
--- a/src/documents/src/engine/docx-strikethrough.js
+++ b/src/documents/src/engine/docx-strikethrough.js
@@ -2,6 +2,10 @@ import { readContextualFlag } from './docx-contextual-spacing.js';
import { UnsupportedDocumentError } from './errors.js';
export function readStrike(rPr) {
const single = readContextualFlag(rPr, 'strike'), double = readContextualFlag(rPr, 'dstrike');
- if (single !== undefined && double !== undefined) throw new UnsupportedDocumentError('strike and dstrike on the same property set');
+ if (single !== undefined && double !== undefined) {
+ // Both off, or one form on with the other off, state one appearance. Both on would need a choice.
+ if (single && double) throw new UnsupportedDocumentError('strike and dstrike both on in the same property set');
+ return { strikethrough: single ? 'singleStrikeDoubleOff' : double ? 'doubleStrikeSingleOff' : 'bothStrikeOff' };
+ }
return single !== undefined ? { strikethrough: single ? 'single' : 'singleOff' } : double !== undefined ? { strikethrough: double ? 'double' : 'doubleOff' } : {};
}
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 c1d4092f4..f05cb9245 100644
--- a/src/documents/src/engine/docx.js
+++ b/src/documents/src/engine/docx.js
@@ -1,3 +1,4 @@
+import { readVmlWatermark, watermarkImagePart } from './vml-watermarks.js';
import { writeTableStyleDefinition } from './docx-table-styles.js';
import { MAX_DOCUMENT_CHARACTERS, DOCUMENT_CHARACTER_LIMIT_MESSAGE } from './text-limits.js';
import { rejectFormatChange } from './revisions.js';
@@ -43,7 +44,6 @@ import { shapeHasFields } from './shape-page-fields.js';
import { controlSignature, controlContentSignature, contentControlRanges, changedContentControls } from './content-controls.js';
import { MC, alternateBranch, activeDescendants, serializeFragment, bindUsedNamespaces } from './docx-compatibility.js';
import { readParagraphBorders, writeParagraphBorders } from './docx-paragraph-borders.js';
-import { checkJustificationText } from './justification.js';
import { readParagraphShading, writeParagraphShading } from './docx-paragraph-shading.js';
import { hyperlinkReader, hyperlinkWriter, bindHyperlinkNamespaces } from './docx-hyperlinks.js';
import { hyperlinkBoundaryChanges } from './hyperlinks.js';
@@ -62,7 +62,7 @@ import { underlinePattern } from './underlines.js';
import { readCapitalization } from './docx-capitalization.js';
import { checkedCapitalization, resolveCapitalization, capitalizationDeclarations } from './capitalization.js';
import { readStrike } from './docx-strikethrough.js';
-import { checkedStrike, resolveStrike } from './strikethrough.js';
+import { checkedStrike, resolveStrike, strikeDeclarations } from './strikethrough.js';
import { readLigatures, writeLigatures, readOpenTypeFeatures } from './ligatures.js';
import { readKerning } from './docx-kerning.js';
import { readContextualFlag } from './docx-contextual-spacing.js';
@@ -78,11 +78,11 @@ import { colorReader } from './docx-colors.js';
import { readTable, writeTable } from './docx-tables.js';
import { imageReader, imageWriter, bindImageNamespaces, WP } from './docx-images.js';
import { resizedPictureShell } from './picture-resize.js';
-import { OBJECT } from './images.js';
+import { OBJECT, imageInfo } from './images.js';
import { isPageBasedCellPosition, isCellRelativePosition } from './positioned-images.js';
import { documentBlocks } from './tables.js';
-import { emptyStyles, resolveStyledParagraph, resolveCharacterRun, styleProperties, paragraphRunStyle, styleRun } from './styles.js';
-import { resolvedFormat, geometryPatch, checkedRunFormat, PARAGRAPH_FLAGS } from './formatting.js';
+import { emptyStyles, resolveStyledParagraph, resolveCharacterRun, styleProperties, paragraphRunStyle, styleRun, characterUnit } from './styles.js';
+import { resolvedFormat, geometryPatch, checkedRunFormat, PARAGRAPH_FLAGS, characterIndent } from './formatting.js';
import { levelParagraphPatch } from './numbering-paragraph.js';
import { UnsupportedDocumentError } from './errors.js';
export { UnsupportedDocumentError } from './errors.js';
@@ -92,7 +92,9 @@ 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, relationshipPartPath, partPathForUrl } 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';
@@ -251,10 +253,17 @@ function writeParagraphFormat(node, p, make, numbering) {
for (const [key, physical, logical] of [['indentLeft', 'left', 'start'], ['indentRight', 'right', 'end']]) if (key in patch) {
// Both spellings keep the override effective for older and newer consumers.
for (const name of [physical, logical]) ind.setAttributeNS(W, `w:${name}`, String(format[key]));
+ // A character-unit form (§17.3.1.12), declared here or inherited, would override the twips; zero turns it off.
+ if (format.characterIndents?.includes(key) || ind.hasAttributeNS(W, physical + 'Chars') || ind.hasAttributeNS(W, logical + 'Chars')) {
+ ind.setAttributeNS(W, `w:${physical}Chars`, '0');
+ if (ind.hasAttributeNS(W, logical + 'Chars')) ind.setAttributeNS(W, `w:${logical}Chars`, '0');
+ }
}
if ('firstLine' in patch) {
- for (const name of ['firstLine', 'hanging']) ind.removeAttributeNS(W, name);
+ const characters = format.characterIndents?.includes('firstLine') || ['firstLineChars', 'hangingChars'].some(name => ind.hasAttributeNS(W, name));
+ for (const name of ['firstLine', 'hanging', 'firstLineChars', 'hangingChars']) ind.removeAttributeNS(W, name);
ind.setAttributeNS(W, format.firstLine < 0 ? 'w:hanging' : 'w:firstLine', String(Math.abs(format.firstLine)));
+ if (characters) ind.setAttributeNS(W, format.firstLine < 0 ? 'w:hangingChars' : 'w:firstLineChars', '0');
}
}
if ('tabs' in patch) {
@@ -357,11 +366,13 @@ function writeRunFormat(properties, patch, make, inheritedStrike = 'none', inher
if ('strikethrough' in patch) {
properties ||= make('rPr');
const mode = patch.strikethrough, base = inheritedStrike === 'double' ? 'double' : 'single';
- const kind = mode === 'none' ? base : mode.startsWith('double') ? 'double' : 'single';
for (const tag of ['strike', 'dstrike']) for (const n of children(properties)) if (n.namespaceURI === W && n.localName === tag) n.remove();
- const tag = kind === 'single' ? 'strike' : 'dstrike', node = make(tag);
- node.setAttributeNS(W, 'w:val', mode === 'single' || mode === 'double' ? '1' : '0');
- properties.insertBefore(node, children(properties).find(n => runOrder.indexOf(n.localName) > runOrder.indexOf(tag)) || null);
+ // A pair of declarations (both off, or one on and the other off) writes both elements.
+ for (const value of mode === 'none' ? [base + 'Off'] : strikeDeclarations(mode)) {
+ const tag = value.startsWith('double') ? 'dstrike' : 'strike', node = make(tag);
+ node.setAttributeNS(W, 'w:val', value === 'single' || value === 'double' ? '1' : '0');
+ properties.insertBefore(node, children(properties).find(n => runOrder.indexOf(n.localName) > runOrder.indexOf(tag)) || null);
+ }
}
for (const [key, tag] of [['bold', 'b'], ['italic', 'i'], ['boldCs', 'bCs'], ['italicCs', 'iCs'], ['fontSizeCs', 'szCs'], ['cs', 'cs'], ['rtl', 'rtl'], ['noProof', 'noProof'], ['vanish', 'vanish'], ['color', 'color'], ['characterSpacing', 'spacing'], ['textScale', 'w'], ['kerning', 'kern'], ['baselineShift', 'position'], ['fontSize', 'sz'], ['highlight', 'highlight'], ['underline', 'u'], ['shading', 'shd'], ['verticalAlign', 'vertAlign']]) {
if (!(key in patch) && !(key === 'underline' && ('underlineColor' in patch || 'underlineStyle' in patch))) continue;
@@ -718,7 +729,7 @@ function writeStyles(parts, styles) {
// paragraph writer intentionally authors both; do not freeze inherited
// values (or undo an explicit removal) when editing a shared style.
const explicit = Object.fromEntries(Object.entries(pp).filter(([k,v]) => v !== null && !['lineSpacing', 'lineRule'].includes(k)));
- writeParagraphFormat(wrapper, { shell: true, formatOverrides: explicit, format: resolvedFormat(styleProperties(styles, style.id)), inheritedTabPositions: [] }, make);
+ writeParagraphFormat(wrapper, { shell: true, formatOverrides: explicit, format: { ...resolvedFormat(styleProperties(styles, style.id), characterUnit(styles)), characterIndents: characterIndent(style.basedOn ? styleProperties(styles, style.basedOn).ind : styles.defaults.properties.ind, characterUnit(styles)).keys }, inheritedTabPositions: [] }, make);
for (const [key, attr] of [['lineSpacing', 'line'], ['lineRule', 'lineRule']]) if (key in pp && pp[key] !== null) {
if (!pPr()) wrapper.prepend(make('pPr'));
let spacing = child(pPr(), 'spacing');
@@ -1060,6 +1071,10 @@ function paragraphWriter(doc, styles, numbering, originals, writeImage, writeLin
if (run.text !== OBJECT) throw new Error('An equation must occupy one document position.');
parent.append(doc.importNode(xml(run.math.xml).documentElement, true)); continue;
}
+ if (run.vml) {
+ if (run.text !== OBJECT) throw new Error('A retained VML object must occupy one document position.');
+ r.append(doc.importNode(xml(run.vml.xml).documentElement, true)); place(r, run); continue;
+ }
if (run.noBreakHyphen) {
if (run.text !== '\u2011') throw new Error('Invalid retained nonbreaking hyphen.');
r.append(make('noBreakHyphen')); place(r, run); continue;
@@ -1121,6 +1136,34 @@ 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;
+}
+
+// Word's page-number building blocks (Insert > Page Number, "Page X of Y") wrap a header or footer's
+// paragraphs in block-level document-part controls (§17.5.2.13 docPartObj), sometimes one inside another.
+// Such a story is edited like any other: the wrappers are kept as block controls and written back
+// around their paragraphs. Any other control (inline, locked, or of another kind) keeps the story retained.
+function documentPartStory(root) {
+ const controls = [...root.getElementsByTagNameNS(W, 'sdt')];
+ if (!controls.length || root.namespaceURI !== W || !['hdr', 'ftr'].includes(root.localName)) return false;
+ return controls.every(control => {
+ const parent = control.parentElement;
+ const block = parent === root || parent?.namespaceURI === W && parent.localName === 'sdtContent' && parent.parentElement?.localName === 'sdt' && parent.parentElement.parentElement && (parent.parentElement.parentElement === root || parent.parentElement.parentElement.localName === 'sdtContent');
+ const properties = children(control).find(n => n.namespaceURI === W && n.localName === 'sdtPr');
+ const own = properties ? children(properties) : [];
+ return block && own.some(n => n.namespaceURI === W && n.localName === 'docPartObj')
+ && own.every(n => n.namespaceURI === W && (['id', 'docPartObj', 'rPr', 'alias', 'tag'].includes(n.localName) || n.localName === 'lock' && n.getAttributeNS(W, 'val') === 'unlocked'));
+ });
+}
+
export class DocxPackage {
constructor(parts, paragraphs, originals = new Map(), pageSettings, numbering = emptyNumbering(), styles, tables = [], assets = {}) {
this.assets = assets;
@@ -1142,6 +1185,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 => {
@@ -1150,6 +1195,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')))) {
@@ -1538,6 +1584,18 @@ export class DocxPackage {
let display;try{display=formatPageNumber(1,noteFormats.get(sourceNode)||defaultNoteFormat);}catch(e){throw new UnsupportedDocumentError(e.message);}
push();text=display;push();runs.at(-1).noteMark={kind:t.localName,text:display};
}
+ else if (t.namespaceURI === W && t.localName === 'pict' && context.pageStory && !context.drawingText && readVmlWatermark(t)) {
+ // A Word watermark (a VML shape behind the text, in a header) is one retained object: its XML
+ // is saved unchanged and what it describes is drawn behind each page's text.
+ const watermark = readVmlWatermark(t);
+ // A picture watermark's image is decoded with the other pictures, for drawing; its part is never rewritten.
+ if (watermark.kind === 'picture') {
+ const path = watermarkImagePart(parts, context.pageStory, watermark.imageRel, { parseXml: xml, relationshipPartPath, partPathForUrl });
+ let info = null; try { info = path && imageInfo(parts[path]); } catch { info = null; }
+ if (info && ['image/png', 'image/jpeg', 'image/gif'].includes(info.mime)) { assets[path] ||= { bytes: parts[path], mime: info.mime }; watermark.assetId = path; }
+ }
+ push(); text = OBJECT; push(); runs.at(-1).vml = { xml: serializeFragment(t), watermark };
+ }
else if (t.namespaceURI === W && t.localName === 'cr') text += '\v';
else if (t.namespaceURI === W && t.localName === 'drawing') pushDrawing(t, () => paragraphImageReader(t));
else if (t.namespaceURI === MC && t.localName === 'AlternateContent') {
@@ -1699,7 +1757,6 @@ export class DocxPackage {
for (const control of [...controls, ...(context.blockControls || [])]) resolveCharacterRun({ ...control.inputRun, text: '' }, styles, paragraphRunStyle(p, styles), { displayOnly: preview });
} catch (e) { throw e instanceof UnsupportedDocumentError ? e : new UnsupportedDocumentError(e.message); }
if (controls.length && changedContentControls([p]).size) throw new UnsupportedDocumentError('content control boundaries inside grapheme clusters');
- checkJustificationText(paragraphText(p), p.format.alignment);
if (p.cellId && p.format.pageBreakBefore) {
if (!preview) throw new UnsupportedDocumentError('paragraph page breaks inside table cells');
issues.delete('paragraph page breaks inside table cells');
@@ -1760,7 +1817,7 @@ export class DocxPackage {
result.push(p);
}
else if (node.localName === 'sdt') {
- const { control, content } = readControl(node, 'block');
+ const { control, content } = readControl(node, 'block', { sharedId: !!context.pageStory });
if (control.persistent && children(content).some(n => n.namespaceURI === W && n.localName === 'tbl'))
throw new UnsupportedDocumentError('persistent controls around tables');
control.scope = context.cellId || null;
@@ -1780,7 +1837,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
@@ -1867,10 +1925,11 @@ export class DocxPackage {
const activeStories = pkg.pageStories ? activePageStoryPaths(pkg.pageStories) : new Set();
for (const [path, story] of Object.entries(pkg.pageStories?.stories || {})) {
if (!activeStories.has(path)) { story.paragraphs = []; story.tables = []; continue; }
- story.paragraphs = attempt(() => {
+ const readStory = documentParts => {
const root = xml(parts[path]).documentElement;
- story.editable = !preview && !hasPageStoryControls(root);
- if (preview) {
+ story.editable = !preview && (documentParts || !hasPageStoryControls(root));
+ if (documentParts) { /* Block document-part wrappers are read as block controls below. */ }
+ else if (preview) {
projectControlContents(root);
projectCachedFieldResults(root, styles, field => {
// A picture result (INCLUDEPICTURE) shows as its picture in the preview.
@@ -1883,7 +1942,7 @@ export class DocxPackage {
} else projectRetainedStoryControls(root);
// Tables and relationships have story-local registries.
// In preview, let readBlocks recover locally instead of losing the story.
- if (children(root).some(n => n.namespaceURI !== W || !['p', 'tbl'].includes(n.localName))) unsupported('page story blocks not yet implemented');
+ if (children(root).some(n => n.namespaceURI !== W || !['p', 'tbl', ...(documentParts ? ['sdt'] : [])].includes(n.localName))) unsupported('page story blocks not yet implemented');
// Story bookmarks still need an editable identity scope.
// Runs, drawings, scoped hyperlinks and page fields use strict readers. Proofing marks
// (§17.13.8.1 proofErr, cached spelling results) are left out of a rewritten story.
@@ -1895,6 +1954,12 @@ export class DocxPackage {
documentBlocks(paragraphs, story.tables);
checkMarkers(paragraphs);
return paragraphs;
+ };
+ story.paragraphs = attempt(() => {
+ const documentParts = !preview && documentPartStory(xml(parts[path]).documentElement);
+ if (!documentParts) return readStory(false);
+ // Wrappers the control reader refuses keep the story retained, shown as before.
+ try { return readStory(true); } catch (error) { if (!(error instanceof UnsupportedDocumentError)) throw error; return readStory(false); }
}, null);
if (story.paragraphs) pkg.storyOriginals.set(path, {
signature: JSON.stringify({ paragraphs: story.paragraphs, tables: story.tables }),
@@ -2179,7 +2244,7 @@ export class DocxPackage {
root = children(doc.documentElement).find(n => n.namespaceURI === W && n.localName === element && n.getAttributeNS(W, 'id') !== null && BigInt(n.getAttributeNS(W, 'id')).toString() === noteId);
if (!root) { root = doc.createElementNS(W, `w:${element}`); root.setAttributeNS(W, 'w:id', noteId); doc.documentElement.append(root); }
}
- if (hasPageStoryControls(root)) throw new Error('Page stories containing content controls are retained without editing.');
+ if (hasPageStoryControls(root) && (noteId !== null || !documentPartStory(root))) throw new Error('Page stories containing content controls are retained without editing.');
if (paragraphs.some(p => p.sectionEnd || p.bookmarks?.length || p.pageStory && p.pageStory !== part)) throw new Error('Invalid page-story paragraph scope.');
if (changedContentControls(paragraphs).size || changedBlockControls(paragraphs, tables).size) throw new Error('Modified page-story controls are not supported.');
const blocks = documentBlocks(paragraphs, tables);
diff --git a/src/documents/src/engine/drawing-format.js b/src/documents/src/engine/drawing-format.js
index dd1db095c..72979c1df 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();
@@ -162,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,
@@ -174,9 +228,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/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/src/engine/drop-caps.js b/src/documents/src/engine/drop-caps.js
index 7846ff176..99d92a70f 100644
--- a/src/documents/src/engine/drop-caps.js
+++ b/src/documents/src/engine/drop-caps.js
@@ -16,7 +16,7 @@ const halfPoints = value => Math.round(value * 2) / 2;
const capMode = p => ['drop', 'margin'].includes(p?.frameProperties?.dropCap) ? p.frameProperties.dropCap : null;
const paragraphStart = (model, index) => model.paragraphs.slice(0, index).reduce((n, p) => n + paragraphText(p).length + 1, 0);
const copy = value => structuredClone(value);
-const plainRun = run => run.text && !run.image && !run.field && !run.noteMark && !run.math && !run.bodySymbol && !run.contentControl;
+const plainRun = run => run.text && !run.image && !run.field && !run.noteMark && !run.math && !run.vml && !run.bodySymbol && !run.contentControl;
/** The caret's drop-cap frame paragraph (itself, or the one right before it) and its text paragraph. */
function capPair(model) {
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/src/engine/field-results.js b/src/documents/src/engine/field-results.js
index cf753bad9..6ae551660 100644
--- a/src/documents/src/engine/field-results.js
+++ b/src/documents/src/engine/field-results.js
@@ -4,12 +4,14 @@
// document statistics (NUMPAGES, NUMWORDS, NUMCHARS) and the general formatting switch (§17.16.4.3).
// A field is inserted with its current result as cached text, so every consumer shows it at once;
// Update Field recomputes it from the same evaluator.
-import { fieldInstructionTokens } from './docx-page-fields.js';
+import { fieldInstructionTokens, pageFieldSegments, writePageFieldResult } from './docx-page-fields.js';
+import { serializeFragment } from './docx-compatibility.js';
import { formatPageNumber } from './page-numbering.js';
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 +74,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
@@ -163,11 +171,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;
}
@@ -211,10 +230,59 @@ 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 });
}
return changes;
}
+
+const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
+// Results that follow pagination (headers and footers draw them live) or that this evaluator does not compute.
+const LIVE_STORY_FIELDS = new Set(['PAGE', 'NUMPAGES', 'SECTIONPAGES']);
+
+/**
+ * Update Fields in a header or footer (its story model): fields this evaluator computes (DATE, FILENAME,
+ * AUTHOR, NUMWORDS…) get their current result. A story field is one atomic run holding the field's saved
+ * envelope, so the new result is written into that envelope, with the old result's run formatting, as the
+ * saved file will hold it. Locked fields, page numbers and fields it cannot evaluate are left alone.
+ * One undo step; returns the number of fields changed.
+ */
+export function updateStoryFields(model, context = fieldEvaluationContext()) {
+ const changes = [];
+ for (const [pi, p] of model.paragraphs.entries()) for (const [ri, run] of p.runs.entries()) {
+ const field = run.field;
+ if (!field || field.locked || LIVE_STORY_FIELDS.has(field.type)) continue;
+ const value = evaluateField(field.instruction, { ...(context || {}), text: context?.text ?? '' });
+ if (value === null || value === field.cachedText) continue;
+ let source;
+ try {
+ const doc = new DOMParser().parseFromString(``, 'application/xml');
+ const envelope = new DOMParser().parseFromString(`${field.source}`, 'application/xml').documentElement;
+ const [segment] = pageFieldSegments(envelope);
+ const old = segment?.resultNodes?.find(n => n.namespaceURI === W && n.localName === 'r');
+ const result = doc.createElementNS(W, 'w:r');
+ const properties = old && [...old.children].find(n => n.namespaceURI === W && n.localName === 'rPr');
+ if (properties) result.append(doc.importNode(properties, true));
+ const lines = String(value).split(/\r\n?|\n|\v/);
+ lines.forEach((line, i) => {
+ if (i) result.append(doc.createElementNS(W, 'w:br'));
+ if (!line) return;
+ const t = doc.createElementNS(W, 'w:t'); t.setAttribute('xml:space', 'preserve'); t.textContent = line; result.append(t);
+ });
+ source = writePageFieldResult(field, value ? [result] : [], doc).map(serializeFragment).join('');
+ } catch { continue; } // An envelope this writer cannot rewrite keeps its saved result.
+ changes.push({ pi, ri, field: { ...field, source, cachedText: value } });
+ }
+ if (!changes.length) return 0;
+ model.checkpoint();
+ for (const { pi, ri, field } of changes) {
+ const p = model.paragraphs[pi];
+ model.paragraphs[pi] = { ...p, runs: p.runs.map((run, i) => i === ri ? { ...run, field } : run) };
+ }
+ model.didChange();
+ return changes.length;
+}
+
diff --git a/src/documents/src/engine/field-update.js b/src/documents/src/engine/field-update.js
index a5d78ce79..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);
@@ -132,6 +135,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 +148,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 +157,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/src/engine/formatting-marks.js b/src/documents/src/engine/formatting-marks.js
index 32af1c3df..7e4f95048 100644
--- a/src/documents/src/engine/formatting-marks.js
+++ b/src/documents/src/engine/formatting-marks.js
@@ -80,6 +80,8 @@ export function formattingMarks(lines, text, paragraphs, pageSettings, page) {
if (ch === ' ' || ch === ' ') marks.push({ kind: 'space', x: (x + next) / 2, y: baseline, size, cell: line });
else if (ch === ' ') marks.push({ kind: 'nbsp', x: (x + next) / 2, y: baseline, size, cell: line });
else if (ch === '\t') marks.push({ kind: 'tab', x, width: next - x, y: baseline, size, cell: line });
+ // An optional hyphen shows as ¬ where it does not break the line; where it does, its hyphen is drawn.
+ else if (ch === '\u00ad' && !(offset === local.end - 1 && !local.paragraphEnd && !local.hardBreak)) marks.push({ kind: 'optionalHyphen', x, y: baseline, size, cell: line });
}
for (const entry of entries?.get(line.paragraphId) || []) {
if (entry.offset < local.start || entry.offset > local.end || (entry.offset === local.end && !local.paragraphEnd)) continue;
@@ -148,6 +150,12 @@ export function paintFormattingMarks(ctx, marks, geometry) {
const { x, y, size } = mark;
if (mark.kind === 'space') ctx.fillRect(x - size * 0.06, y - size * 0.36, Math.max(1, size * 0.12), Math.max(1, size * 0.12));
else if (mark.kind === 'nbsp') { ctx.font = `${size * 0.7}px system-ui, sans-serif`; ctx.textAlign = 'center'; ctx.fillText('°', x, y - size * 0.05); }
+ // The hyphen has no width of its own and the text is not reflowed around it: the mark is drawn
+ // small and raised between its letters, over a hairline at their join, so it stays legible.
+ else if (mark.kind === 'optionalHyphen') {
+ ctx.fillRect(x - 0.25, y - size * 0.62, 0.5, size * 0.62);
+ ctx.font = `bold ${size * 0.55}px system-ui, sans-serif`; ctx.textAlign = 'center'; ctx.fillText('¬', x, y - size * 0.72);
+ }
else if (mark.kind === 'tab') {
// An arrow centered in the tab's gap, as long as fits.
const length = Math.min(mark.width - 2, size * 0.75), left = x + (mark.width - length) / 2, mid = y - size * 0.32, head = Math.min(size * 0.22, length / 2);
diff --git a/src/documents/src/engine/formatting.js b/src/documents/src/engine/formatting.js
index d4ee514b2..74107696d 100644
--- a/src/documents/src/engine/formatting.js
+++ b/src/documents/src/engine/formatting.js
@@ -26,12 +26,29 @@ export const PARAGRAPH_FORMAT = Object.freeze({ paragraphBorders: Object.freeze(
export const RUN_FORMAT = Object.freeze({ ...FONT_DEFAULTS, bold: false, italic: false, boldCs: false, italicCs: false, fontSizeCs: 12, cs: false, rtl: false, noProof: false, underline: false, underlineStyle: 'none', strikethrough: 'none', capitalization: 'normal', fontSize: 12, textOutline: null, textShadow: null, textFill: null, text3d: null, textScene: null, color: 'auto', underlineColor: 'auto', highlight: 'none', shading: 'auto', verticalAlign: 'baseline', baselineShift: 0, characterSpacing: 0, textScale: 100, kerning: 0, ligatures: 'none', vanish: false });
export const runFormat = run => { const result = Object.fromEntries(Object.entries(RUN_FORMAT).map(([k, v]) => [k, run?.[k] ?? v])); result.underlineStyle = underlinePattern(run); result.underline = result.underlineStyle !== 'none'; if (run?.legacyKerning !== undefined) result.legacyKerning = run.legacyKerning; return result; };
-export function resolvedFormat(properties) {
- const s = properties.spacing || {}, ind = properties.ind || {};
+/**
+ * Indents with their character-unit forms applied (§17.3.1.12 ind): a nonzero leftChars, rightChars,
+ * firstLineChars or hangingChars, in hundredths of a character unit, overrides its twips companion. A
+ * zero leaves the twips value in force. `unit` is the character unit in twips (or a function returning
+ * it); `keys` lists the format keys that came from character units.
+ */
+export function characterIndent(ind = {}, unit) {
+ if (!Object.keys(ind).some(k => k.endsWith('Chars') && ind[k] !== 0)) return { ind, keys: [] };
+ const size = typeof unit === 'function' ? unit() : unit;
+ if (!Number.isFinite(size) || size <= 0) throw new UnsupportedDocumentError('character-unit paragraph indents');
+ const result = { ...ind }, keys = [], twips = name => Math.round(ind[name] * size / 100);
+ if (ind.leftChars) { result.left = twips('leftChars'); keys.push('indentLeft'); }
+ if (ind.rightChars) { result.right = twips('rightChars'); keys.push('indentRight'); }
+ if (ind.hangingChars) { result.hanging = twips('hangingChars'); delete result.firstLine; keys.push('firstLine'); }
+ else if (ind.firstLineChars) { result.firstLine = twips('firstLineChars'); delete result.hanging; keys.push('firstLine'); }
+ return { ind: result, keys };
+}
+
+export function resolvedFormat(properties, characterUnit) {
+ const s = properties.spacing || {}, { ind, keys: characterIndents } = characterIndent(properties.ind, characterUnit);
const outlineLevel = properties.outlineLevel ?? 9;
if (!Number.isInteger(outlineLevel) || outlineLevel < 0 || outlineLevel > 9) throw new UnsupportedDocumentError('outline levels outside 0–9');
if (properties.bidi) throw new UnsupportedDocumentError('right-to-left paragraph indents');
- if (Object.keys(ind).some(k => k.endsWith('Chars') && ind[k] !== 0)) throw new UnsupportedDocumentError('character-unit paragraph indents');
const geometry = { indentLeft: ind.left ?? 0, indentRight: ind.right ?? 0, firstLine: ind.hanging !== undefined ? -ind.hanging : ind.firstLine ?? 0 };
try {
for (const v of Object.values(geometry)) validateIndent(v);
@@ -39,7 +56,7 @@ export function resolvedFormat(properties) {
geometry.tabs = normalizeTabStops(properties.tabs || []);
} catch (e) { throw new UnsupportedDocumentError(e.message); }
const alignment = properties.alignment || 'left';
- if (!['left', 'center', 'right', 'start', 'end', 'both'].includes(alignment)) throw new UnsupportedDocumentError(`${alignment} paragraph alignment`);
+ if (!['left', 'center', 'right', 'start', 'end', 'both', 'distribute'].includes(alignment)) throw new UnsupportedDocumentError(`${alignment} paragraph alignment`);
const lineRule = s.line === undefined ? 'auto' : s.lineRule ?? 'auto';
const lineSpacing = s.line === undefined ? 1 : Number(s.line) / (lineRule === 'auto' ? 240 : 20);
// Paragraph line units measure 12pt, independently of text size
@@ -62,7 +79,7 @@ export function resolvedFormat(properties) {
}
const pagination = Object.fromEntries(Object.entries(PAGINATION_DEFAULTS).map(([key, fallback]) => [key, properties[key] ?? fallback]));
const flags = Object.fromEntries(Object.entries(PARAGRAPH_FLAGS).map(([key, fallback]) => [key, properties[key] ?? fallback]));
- return { ...geometry, ...pagination, ...flags, ...automatic, paragraphBorders: checkedParagraphBorders(properties.paragraphBorders || {}), paragraphShading: checkedShading(properties.paragraphShading ?? 'auto'), contextualSpacing: properties.contextualSpacing ?? false, outlineLevel, alignment: alignment === 'start' ? 'left' : alignment === 'end' ? 'right' : alignment, lineRule, lineSpacing, spaceBefore, spaceAfter };
+ return { ...geometry, ...(characterIndents.length ? { characterIndents } : {}), ...pagination, ...flags, ...automatic, paragraphBorders: checkedParagraphBorders(properties.paragraphBorders || {}), paragraphShading: checkedShading(properties.paragraphShading ?? 'auto'), contextualSpacing: properties.contextualSpacing ?? false, outlineLevel, alignment: alignment === 'start' ? 'left' : alignment === 'end' ? 'right' : alignment, lineRule, lineSpacing, spaceBefore, spaceAfter };
}
// Explicit point edits switch off inherited/direct automatic spacing. Retain
@@ -77,9 +94,9 @@ export function clearAutomaticSpacing(format, patch) {
// Geometry and contextual spacing are supported on numbering levels. Preserve the raw
// presence of each attribute so paragraph and direct formatting can override it.
-export function geometryPatch(properties, checkCharacters = false) {
- const ind = properties.ind || {}, result = {};
- if (checkCharacters && Object.keys(ind).some(k => k.endsWith('Chars') && ind[k] !== 0)) throw new UnsupportedDocumentError('character-unit numbering indents');
+export function geometryPatch(properties, checkCharacters = false, characterUnit) {
+ if (checkCharacters && Object.keys(properties.ind || {}).some(k => k.endsWith('Chars') && properties.ind[k] !== 0)) throw new UnsupportedDocumentError('character-unit numbering indents');
+ const ind = characterUnit ? characterIndent(properties.ind, characterUnit).ind : properties.ind || {}, result = {};
if ((ind.hanging ?? 0) < 0 || (ind.firstLine ?? 0) < 0) throw new UnsupportedDocumentError('negative first-line or hanging distance');
if ('left' in ind) result.indentLeft = ind.left;
if ('right' in ind) result.indentRight = ind.right;
diff --git a/src/documents/src/engine/hidden-text.js b/src/documents/src/engine/hidden-text.js
new file mode 100644
index 000000000..46d5c0298
--- /dev/null
+++ b/src/documents/src/engine/hidden-text.js
@@ -0,0 +1,63 @@
+import { locate } from './model.js';
+import { revisionView, runRevisionDisplay } from './revisions.js';
+
+/**
+ * Text the view hides: a tracked deletion in Simple Markup or No Markup (or an insertion in Original), and hidden
+ * text (w:vanish) while hidden text is not shown. It is laid out with no width, as layout.js decides from the
+ * same view, so its caret stops all sit where the visible text around it meets. Caret movement and deletion
+ * pass over it: an arrow press that only crossed it, or a Backspace that removed (or struck out again) text no
+ * one can see, did nothing visible, and typing went into the middle of a hidden deletion.
+ */
+export function hiddenRun(run) {
+ if (!run?.text) return false;
+ if (run.vanish && !revisionView.showHidden) return true;
+ return !!(run.revision && runRevisionDisplay(run)?.hidden);
+}
+
+/** The hidden stretch of a paragraph that `position` touches or lies in, as [start, end], or null. */
+export function hiddenStretchAt(paragraphs, position) {
+ if (!paragraphs.length) return null;
+ const { index, offset } = locate(paragraphs, position), base = position - offset;
+ let at = 0, start = null;
+ for (const run of paragraphs[index].runs) {
+ const end = at + run.text.length;
+ if (hiddenRun(run)) { if (start === null) start = at; }
+ else if (run.text) {
+ if (start !== null && start <= offset && offset <= at) return [base + start, base + at];
+ start = null;
+ }
+ at = end;
+ }
+ return start !== null && start <= offset && offset <= at ? [base + start, base + at] : null;
+}
+
+/** Whether every character between two positions of one paragraph is hidden (they show as one caret place). */
+export function sameShownPlace(paragraphs, a, b) {
+ if (a === b) return true;
+ const stretch = hiddenStretchAt(paragraphs, Math.min(a, b));
+ return !!stretch && stretch[0] <= Math.min(a, b) && Math.max(a, b) <= stretch[1];
+}
+
+/**
+ * A caret step from `from` (by `step`, in `direction`) that a reader can see: hidden text is passed over whole,
+ * and a step that crossed only hidden text goes on from its far side.
+ */
+export function pastHiddenText(paragraphs, from, direction, step) {
+ let target = step(from);
+ for (let i = 0; i < 16; i++) {
+ const stretch = hiddenStretchAt(paragraphs, target);
+ if (stretch && stretch[0] < target && target < stretch[1]) target = direction < 0 ? stretch[0] : stretch[1];
+ if (target === from || !sameShownPlace(paragraphs, from, target)) return target;
+ const crossed = hiddenStretchAt(paragraphs, target);
+ const next = crossed ? (direction < 0 ? crossed[0] : crossed[1]) : target;
+ if (next !== target) return next;
+ from = target; target = step(from);
+ }
+ return target;
+}
+
+/** A position inside hidden text moved to where the caret shows: just past that text. */
+export function outsideHiddenText(paragraphs, position) {
+ const stretch = hiddenStretchAt(paragraphs, position);
+ return stretch && stretch[0] < position && position < stretch[1] ? stretch[1] : position;
+}
diff --git a/src/documents/src/engine/home-editing.js b/src/documents/src/engine/home-editing.js
index 8ee8ef712..bdb84dc74 100644
--- a/src/documents/src/engine/home-editing.js
+++ b/src/documents/src/engine/home-editing.js
@@ -19,7 +19,7 @@ export function wordAt(text, position) {
return null;
}
-const objectRun = run => run.image || run.field || run.noteMark || run.bodySymbol || run.contentControl?.picture;
+const objectRun = run => run.image || run.vml || run.field || run.noteMark || run.bodySymbol || run.contentControl?.picture;
const isUpper = text => text === text.toLocaleUpperCase() && text !== text.toLocaleLowerCase();
const isLower = text => text === text.toLocaleLowerCase() && text !== text.toLocaleUpperCase();
@@ -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/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/src/engine/inline-display-text.js b/src/documents/src/engine/inline-display-text.js
index 8f2a8830a..c288cb42a 100644
--- a/src/documents/src/engine/inline-display-text.js
+++ b/src/documents/src/engine/inline-display-text.js
@@ -8,6 +8,8 @@ export function inlineDisplayText(run, text) {
// A custom-marked note reference shows its following mark text instead of a number.
if (run?.noteMark?.display !== undefined && text === '\ufffc') return run.noteMark.display || (run.noteMark.custom ? '\u200b' : '\u2002');
if (run?.math && text === '\ufffc') return run.math.text;
+ // A retained VML watermark is drawn behind the page, not in the line: it takes no room there.
+ if (run?.vml && text === '\ufffc') return '\u200b';
if (run?.noBreakHyphen) return text.replace('\u2011', '-');
return bodySymbolText(run, text);
}
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/src/engine/justification.js b/src/documents/src/engine/justification.js
index 58e9edfae..5366f7bba 100644
--- a/src/documents/src/engine/justification.js
+++ b/src/documents/src/engine/justification.js
@@ -1,21 +1,56 @@
-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.
export const justificationSpace = text => text === ' ' || text === '\u00a0';
-export function checkJustificationText(text, alignment) {
- if (alignment === 'both' && /\p{Script=Arabic}/u.test(text)) throw new UnsupportedDocumentError('Arabic kashida justification');
+// Arabic is justified by its word spaces as well. Kashida elongation (U+0640 or stretched joins), which
+// the reference application can add, is not drawn: justified Arabic lines fill the measure by their
+// spaces alone. Text and source runs never acquire kashida characters.
+
+/**
+ * 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;
+}
+
+// Distributed alignment widens the gap after every character of the range alike, spaces included.
+function characterGaps(span, first, last) {
+ const gaps = new Set();
+ for (let i = first; i < last - 1; i++) if (!span[i].image && !span[i + 1].image && !span[i].ignoredCellBreak && !span[i + 1].ignoredCellBreak && span[i].text) gaps.add(i);
+ return gaps;
}
-export function justificationRange(span, measured, right, tab) {
+export function justificationRange(span, measured, right, tab, distribute = false) {
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 = distribute ? 0 : span.slice(first, last).filter(c => justificationSpace(c.text)).length;
const extra = right - measured.stops.at(-1).x + measured.ignoredTrailingWidth;
+ // Earlier fields stay anchored. Justification can expand the final left tab field. A final
+ // center, right or decimal field is aligned on its stop: widening it would move that alignment,
+ // so the line keeps its natural spacing (a declared approximation; no reference rule is claimed).
+ if (tab && tab.alignment !== 'left') return null;
+ const ideographs = distribute ? characterGaps(span, first, last) : ideographicGaps(span, first, last);
+ const gaps = spaces + ideographs.size;
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 };
+ return { first, last, gaps, spacing: extra / gaps, ideographs, distribute };
+}
+
+/** The style of span[index] on a line justified (or distributed) 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.
+ const words = range.distribute ? 0 : spacing;
+ return range.ideographs.has(index)
+ ? { ...style, justificationSpacing: words, characterSpacing: (style.characterSpacing || 0) + spacing * 3 / 4 }
+ : { ...style, justificationSpacing: words };
}
diff --git a/src/documents/src/engine/layout.js b/src/documents/src/engine/layout.js
index c1303d7f2..055cad72d 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 { 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';
@@ -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)}`;
@@ -547,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;
@@ -822,7 +824,6 @@ function measureParagraph(p, documentOffset, measure, page, listMarker, numberin
}
const floating = floatingParagraph(p, documentOffset, format, page);
if (floating) return floating;
- checkJustificationText(text, format.alignment);
if (cellBounds && page.allowSpaceOfSameStyleInTable) format.contextualSpacing = false;
// Within a row, the next cell's first source paragraph supplies the context.
// At the row end, an implicit Normal-style boundary is used instead.
@@ -862,9 +863,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.
@@ -992,13 +994,15 @@ function measureParagraph(p, documentOffset, measure, page, listMarker, numberin
}
}
const lastLine = end === chars.length || hardBreak === 'page' && end === chars.length - 1 && !page.splitPgBreakAndParaMark;
- if (format.alignment === 'both' && !contraction && !lastLine && !(hardBreak === 'line' && page.doNotExpandShiftReturn)) {
- const range = justificationRange(span, measured, right, builder.field.tab);
+ // Distributed alignment (§17.18.44 distribute) spreads every line, the last one included.
+ const distribute = format.alignment === 'distribute';
+ if (distribute || format.alignment === 'both' && !contraction && !lastLine && !(hardBreak === 'line' && page.doNotExpandShiftReturn)) {
+ const range = justificationRange(span, measured, right, builder.field.tab, distribute);
if (range) {
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/src/engine/line-numbers.js b/src/documents/src/engine/line-numbers.js
index 5158ab140..1757c3751 100644
--- a/src/documents/src/engine/line-numbers.js
+++ b/src/documents/src/engine/line-numbers.js
@@ -6,6 +6,7 @@ import { paragraphFormat } from './model.js';
import { runFormat } from './formatting.js';
import { styleRun } from './styles.js';
import { AUTO_LINE_NUMBER_DISTANCE } from './line-number-settings.js';
+import { sectionColumnIntervals } from './section-columns.js';
export { LINE_NUMBER_RESTARTS, AUTO_LINE_NUMBER_DISTANCE, checkedLineNumbers } from './line-number-settings.js';
@@ -63,9 +64,17 @@ export function lineNumberLabels(layout, paragraphs, numbering, styles, measure)
const geometry = region.geometry;
const columns = geometry.columns?.count > 1;
const automatic = columns ? Math.min(AUTO_LINE_NUMBER_DISTANCE, Math.max(90, (geometry.columns.space ?? 720) / 2)) : AUTO_LINE_NUMBER_DISTANCE;
- const distance = (settings.distance ?? automatic) / 15;
+ let distance = (settings.distance ?? automatic) / 15;
const text = String(count), width = widthOf(text);
const textLeft = line.columnLeft ?? geometry.left;
+ // With a line between columns, an automatic label of a later column stays between that line and
+ // its column rather than ending on it: where the automatic distance leaves it less than 2 px
+ // clear of the line, it is centered in the space between them. An authored distance is kept.
+ if (columns && geometry.columns.separator && settings.distance == null && line.column > 0) {
+ const intervals = sectionColumnIntervals(geometry.columns, geometry.left * 15, (geometry.width - geometry.left - geometry.right) * 15);
+ const room = textLeft - ((intervals[line.column - 1].right + intervals[line.column].left) / 30 + .5) - width;
+ if (distance > room - 2) distance = Math.max(0, room / 2);
+ }
labels.push({ page: line.page, x: textLeft - distance - width, baseline: line.baseline, text, width, style });
}
return labels;
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..f8dda20e1
--- /dev/null
+++ b/src/documents/src/engine/list-numerals.js
@@ -0,0 +1,114 @@
+/**
+ * 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 and iroha syllabaries, Korean counting numerals and digits, the ganada and chosung
+ * sequences, Hebrew letter numerals and alphabet, the Arabic abjad and alphabet, 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', 'koreanCounting', 'koreanDigital', 'hebrew1', 'hebrew2', 'arabicAbjad', 'arabicAlpha', 'irohaFullWidth',
+]);
+
+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.
+// Counting numerals below one hundred million: thousands, hundreds and tens within each group of four,
+// a myriad unit between groups. Chinese keeps 一 before 百 and 千 and writes 零 for skipped places;
+// Japanese and Korean drop the one before every unit within a group and write no zero.
+const CHINESE = { digits: CJK_DIGITS, units: ['千', '百', '十'], myriad: '万', chinese: true };
+const JAPANESE = { digits: CJK_DIGITS, units: ['千', '百', '十'], myriad: '万' };
+const KOREAN = { digits: '영일이삼사오육칠팔구', units: ['천', '백', '십'], myriad: '만' };
+function countingGroup(value, system, first) {
+ let text = '', skipped = false, started = false;
+ for (const [unit, mark] of [[1000, system.units[0]], [100, system.units[1]], [10, system.units[2]], [1, '']]) {
+ const digit = Math.floor(value / unit) % 10;
+ if (!digit) { if (started) skipped = true; continue; }
+ if (skipped && system.chinese) text += '零';
+ skipped = false;
+ // 十 alone stands for 10–19 at the start of a Chinese number; the others drop one before every unit.
+ const omitOne = digit === 1 && mark && (!system.chinese || mark === system.units[2] && first && !started);
+ text += (omitOne ? '' : system.digits[digit]) + mark;
+ started = true;
+ }
+ return text;
+}
+function counting(n, system) {
+ const high = Math.floor(n / 10000), low = n % 10000;
+ if (!high) return countingGroup(low, system, true);
+ return `${countingGroup(high, system, true)}${system.myriad}${low ? (system.chinese && low < 1000 ? '零' : '') + countingGroup(low, system, false) : ''}`;
+}
+
+// Hebrew letter numerals (gematria) below 1000; 15 and 16 are written טו and טז.
+const HEBREW = 'אבגדהוזחטיכלמנסעפצקרשת';
+function hebrewNumeral(value) {
+ let text = '', rest = value % 100;
+ for (let hundreds = Math.floor(value / 100); hundreds > 0; hundreds -= Math.min(hundreds, 4)) text += HEBREW[18 + Math.min(hundreds, 4) - 1];
+ if (rest === 15 || rest === 16) return text + (rest === 15 ? 'טו' : 'טז');
+ if (rest >= 10) { text += HEBREW[9 + Math.floor(rest / 10) - 1]; rest %= 10; }
+ return rest ? text + HEBREW[rest - 1] : text;
+}
+const ABJAD = 'أبجدهوزحطيكلمنسعفصقرشتثخذضظغ', ARABIC = 'أبتثجحخدذرزسشصضطظعغفقكلمنهوي';
+const IROHA = 'イロハニホヘトチリヌルヲワカヨタレソツネナラムウヰノオクヤマケフコエテアサキユメミシヱヒモセス';
+
+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': case 'koreanCounting': return value >= 1 && value < 100000000 ? counting(value, { chineseCounting: CHINESE, japaneseCounting: JAPANESE, koreanCounting: KOREAN }[format]) : value === 0 && format !== 'koreanCounting' ? '〇' : String(value);
+ case 'koreanDigital': return value >= 0 ? String(value).replace(/[0-9]/g, c => KOREAN.digits[c]) : String(value);
+ case 'hebrew1': return value >= 1 && value < 1000 ? hebrewNumeral(value) : String(value);
+ case 'hebrew2': return inSequence(HEBREW, value);
+ case 'arabicAbjad': return inSequence(ABJAD, value);
+ case 'arabicAlpha': return inSequence(ARABIC, value);
+ case 'irohaFullWidth': return inSequence(IROHA, 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/mail-merge-model.js b/src/documents/src/engine/mail-merge-model.js
index a17ad4782..2a2ff60ef 100644
--- a/src/documents/src/engine/mail-merge-model.js
+++ b/src/documents/src/engine/mail-merge-model.js
@@ -84,13 +84,13 @@ function runAt(paragraphs, position) {
return null;
}
// What a result run's formatting is made of: everything but its text and the objects and annotations it holds.
-const NOT_FORMATTING = ['text', 'image', 'contentControl', 'positionalTab', 'noteMark', 'bodySymbol', 'noBreakHyphen', 'breakClear', 'field', 'revision', 'formatRevision', 'math', 'link'];
+const NOT_FORMATTING = ['text', 'image', 'contentControl', 'positionalTab', 'noteMark', 'bodySymbol', 'noBreakHyphen', 'breakClear', 'field', 'revision', 'formatRevision', 'math', 'vml', 'link'];
const resultFormatting = run => { const out = structuredClone(run); for (const key of NOT_FORMATTING) delete out[key]; return out; };
/** Runs with the plain text in [from, to) given `formatting`; text a line break or object holds keeps its own. */
function withFormatting(runs, from, to, formatting) {
const out = []; let at = 0;
- const plain = run => !run.image && !run.math && !run.field && !run.noteMark && !run.bodySymbol && !run.noBreakHyphen && !run.breakClear && !run.contentControl;
+ const plain = run => !run.image && !run.math && !run.vml && !run.field && !run.noteMark && !run.bodySymbol && !run.noBreakHyphen && !run.breakClear && !run.contentControl;
const push = run => {
const last = out.at(-1);
if (last && plain(last) && plain(run) && JSON.stringify({ ...last, text: '' }) === JSON.stringify({ ...run, text: '' })) last.text += run.text;
@@ -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;
}
@@ -206,7 +210,7 @@ export function updateLabels(model) {
const replacement = source.map(p => copyParagraph(p, cell.id));
model.paragraphs.splice(at, end - at, ...replacement);
const head = replacement[0];
- const template = head.runs.find(r => !r.field && !r.image && !r.noteMark && !r.math && !r.bodySymbol) || paragraph('x').runs[0];
+ const template = head.runs.find(r => !r.field && !r.image && !r.noteMark && !r.math && !r.vml && !r.bodySymbol) || paragraph('x').runs[0];
const lead = { ...structuredClone(template), text };
for (const key of ['link', 'revision', 'contentControl', 'styleChanged']) delete lead[key];
head.runs = [lead, ...head.runs];
diff --git a/src/documents/src/engine/math-linear.js b/src/documents/src/engine/math-linear.js
index d69a09791..dca7e40a9 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. */
@@ -472,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)
@@ -486,7 +490,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'))}`;
@@ -515,8 +519,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': {
@@ -539,9 +547,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/src/engine/model.js b/src/documents/src/engine/model.js
index bb1fd39f0..6b6d1c0ba 100644
--- a/src/documents/src/engine/model.js
+++ b/src/documents/src/engine/model.js
@@ -332,7 +332,7 @@ function normalize(runs) {
for (const run of runs) {
if (!run.text && !run.contentControl) continue;
const last = result.at(-1);
- if (last && !last.image && !run.image && !last.math && !run.math && !last.field && !run.field && !last.noteMark && !run.noteMark && !last.bodySymbol && !run.bodySymbol && !last.noBreakHyphen && !run.noBreakHyphen && !last.breakClear && !run.breakClear && JSON.stringify({ ...last, text: '' }) === JSON.stringify({ ...run, text: '' })) last.text += run.text;
+ if (last && !last.image && !run.image && !last.math && !run.math && !last.field && !run.field && !last.noteMark && !run.noteMark && !last.bodySymbol && !run.bodySymbol && !last.vml && !run.vml && !last.noBreakHyphen && !run.noBreakHyphen && !last.breakClear && !run.breakClear && JSON.stringify({ ...last, text: '' }) === JSON.stringify({ ...run, text: '' })) last.text += run.text;
else result.push({ ...run });
}
return result;
@@ -467,8 +467,10 @@ export class TextDocument {
* `[start, end]` is another range to read instead of the selection.
*/
clipboardText(noteText = () => '', [start, end] = this.range) {
- let at = 0, text = '';
- for (const p of this.paragraphs) {
+ let at = 0, text = '', rows = null;
+ // Cells of one row are separated by a tab and rows by a line, as a copied table reads in other applications.
+ const rowOf = id => { if (!rows) { rows = new Map(); for (const table of this.tables) for (const row of table.rows) for (const cell of row.cells) rows.set(cell.id, row); } return rows.get(id); };
+ for (const [index, p] of this.paragraphs.entries()) {
for (const run of p.runs) {
const length = run.text.length;
if (run.image) { if (at >= start && at < end) text += run.image.alt || '[Image]'; }
@@ -477,7 +479,10 @@ export class TextDocument {
else text += run.text.slice(Math.max(0, start - at), Math.max(0, end - at));
at += length;
}
- if (at >= start && at < end) text += '\n';
+ if (at >= start && at < end) {
+ const next = this.paragraphs[index + 1];
+ text += p.cellId && next?.cellId && next.cellId !== p.cellId && rowOf(p.cellId) && rowOf(p.cellId) === rowOf(next.cellId) ? '\t' : '\n';
+ }
at++;
}
return text.replace(/\u000e/g, '\n');
@@ -568,7 +573,7 @@ export class TextDocument {
} else if (Object.hasOwn(PAGINATION_DEFAULTS, key) || Object.hasOwn(PARAGRAPH_FLAGS, key) || key === 'contextualSpacing') {
if (typeof value !== 'boolean') throw new Error('Paragraph pagination must be on or off.');
} else if (key === 'alignment') {
- if (!['left', 'center', 'right', 'both'].includes(value)) throw new Error('Choose left, center, right or justified alignment.');
+ if (!['left', 'center', 'right', 'both', 'distribute'].includes(value)) throw new Error('Choose left, center, right, justified or distributed alignment.');
} else if (key === 'lineRule') {
if (!['auto', 'exact', 'atLeast'].includes(value)) throw new Error('Unsupported line spacing rule.');
} else if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 600) {
@@ -886,6 +891,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;
}
/**
@@ -1057,6 +1064,7 @@ export class TextDocument {
return null;
}
mergeCells() {
+ if (this.trackChanges && !this.trackingInternal) throw new Error('Merging cells cannot be tracked yet. Turn off Track Changes to do this.');
const context = this.mergeCellsContext;
if (!context) throw new Error('Select text in two or more adjacent cells within one row.');
const { table, row, cell, column, index, cells } = context, ids = new Set(cells.map(c => c.id));
@@ -1084,6 +1092,7 @@ export class TextDocument {
return { ...context, owner, below: owners.get(entry.cell.id) };
}
mergeCellBelow() {
+ if (this.trackChanges && !this.trackingInternal) throw new Error('Merging cells cannot be tracked yet. Turn off Track Changes to do this.');
const context = this.mergeBelowContext;
if (!context) throw new Error('The cell below must cover the same grid columns and row alignment. Align the table or split other merged cells first.');
const { table, owner, below } = context, state = tableContent(this, table);
@@ -1302,6 +1311,7 @@ export class TextDocument {
}
deleteTablePart(kind = 'table') {
const context = this.tableContext; if (!context) return false;
+ if (this.trackChanges && !this.trackingInternal) throw new Error(`Deleting ${kind === 'table' ? 'a table' : `a table ${kind}`} cannot be tracked yet. Turn off Track Changes to do this.`);
if (!['table', 'row', 'column'].includes(kind)) throw new Error('Choose a table, row or column.');
const { table, row, rowIndex, column, end, span } = context;
if (kind === 'column' && table.rows.some(row => row.gridBefore || row.gridAfter)) throw new Error('Column deletion in tables with omitted grid cells is not supported yet.');
@@ -1492,7 +1502,7 @@ export class TextDocument {
typingRun(position = this.selection.focus, affinity) {
affinity ??= position === this.selection.focus && this.selection.controlCaret?.side === 'after' ? 'forward' : 'backward';
const source = this.formatAt(position, affinity), run = this.typingDirectCleared ? clearedRun(source) : source;
- delete run.image; delete run.contentControl; delete run.positionalTab; delete run.noteMark; delete run.bodySymbol; delete run.noBreakHyphen; delete run.breakClear; delete run.field; delete run.revision; delete run.math;
+ delete run.image; delete run.contentControl; delete run.positionalTab; delete run.noteMark; delete run.bodySymbol; delete run.noBreakHyphen; delete run.breakClear; delete run.field; delete run.revision; delete run.math; delete run.vml;
// Boundary typing is plain text; strictly interior edits extend the link.
const link = hyperlinkAt(this.paragraphs, position);
if (!link || position <= link.start || position >= link.end) {
@@ -1613,7 +1623,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);
@@ -2352,8 +2364,18 @@ export class TextDocument {
this.formatRejectSkipped = false;
// Resolving changes is not itself a tracked formatting change.
this.formatTrackingSuppressed = true;
- const changes = () => (start === end ? this.revisionsAt(start, end) : revisionRanges(this.paragraphs).filter(r => r.start < end && r.end > start)).filter(r => only(r))
- .map(r => ({ ...r, start: start === end ? r.start : Math.max(start, r.start), end: start === end ? r.end : Math.min(end, r.end) }));
+ const changes = () => {
+ const list = (start === end ? this.revisionsAt(start, end) : revisionRanges(this.paragraphs).filter(r => r.start < end && r.end > start)).filter(r => only(r))
+ .map(r => ({ ...r, start: start === end ? r.start : Math.max(start, r.start), end: start === end ? r.end : Math.min(end, r.end) }));
+ // A move is one named change (§17.13.5.23, .24 moveFromRangeStart/moveToRangeStart): resolving either half
+ // resolves all of both, wherever the other half is. Accepting only the half moved to left the text it was
+ // moved from deleted but pending.
+ const names = new Set(list.filter(r => r.move && r.moveName).map(r => r.moveName));
+ if (!names.size) return list;
+ const moved = r => r.move && names.has(r.moveName);
+ const whole = revisionRanges(this.paragraphs).filter(moved).map(r => ({ ...r, partner: !list.some(t => moved(t) && t.moveName === r.moveName && t.type === r.type) }));
+ return [...list.filter(r => !moved(r)), ...whole].sort((x, y) => x.start - y.start);
+ };
let targets = changes();
// A field inserted or deleted as a whole is resolved with the change its result is part of, or, resolving a
// stretch of the document, whenever it lies inside it (an index entry or empty result has no text of its own).
@@ -2389,7 +2411,7 @@ export class TextDocument {
if (restored.size) { for (const p of restored) p.runs = normalize(p.runs); targets = changes(); }
}
this.trackingInternal = true;
- const caret = targets[0]?.start ?? fieldTargets[0].start;
+ const caret = (targets.find(r => !r.partner) || targets[0])?.start ?? fieldTargets[0].start;
try {
// Changes that keep their text (formatting, and the side of a change being kept) first, while every
// range still holds; then the removals, from the last, so each earlier range stays where it was.
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/src/engine/package-design.js b/src/documents/src/engine/package-design.js
index 2cca0794e..72234ff10 100644
--- a/src/documents/src/engine/package-design.js
+++ b/src/documents/src/engine/package-design.js
@@ -8,6 +8,8 @@
import { strFromU8, strToU8, unzipSync } from 'fflate';
import { relatedPartPath } from './docx-relationships.js';
import { parseXml } from './docx-xml.js';
+import { readVmlWatermark } from './vml-watermarks.js';
+import { imageInfo } from './images.js';
import { relationshipPartPath, relativePartTarget, zipPackage } from './package-part-paths.js';
import { createPageStoryPart } from './docx-page-story-create.js';
import { themeColorTransform } from './colors.js';
@@ -469,14 +471,20 @@ function watermarkHeaders(parts, create) {
return [...paths];
}
const isWatermark = drawing => [...drawing.getElementsByTagNameNS(NS.wp, 'docPr')].some(n => n.getAttribute('name') === WATERMARK_NAME);
+/**
+ * Take the document's watermark out of a header: the drawing this editor wrote, and a watermark Word
+ * wrote (a VML shape in w:pict, vml-watermarks.js), so a new watermark replaces it and Remove
+ * Watermark removes it, as in Word. A run left holding nothing but its properties goes too.
+ */
function removeWatermarkRuns(doc) {
let removed = false;
- for (const drawing of [...doc.getElementsByTagNameNS(W, 'drawing')]) {
- if (!isWatermark(drawing)) continue;
- const run = drawing.parentNode;
- if (run?.namespaceURI === W && run.localName === 'r') run.remove(); else drawing.remove();
- removed = true;
- }
+ const take = node => {
+ const run = node.parentNode;
+ node.remove(); removed = true;
+ if (run?.namespaceURI === W && run.localName === 'r' && [...run.children].every(n => n.namespaceURI === W && n.localName === 'rPr')) run.remove();
+ };
+ for (const drawing of [...doc.getElementsByTagNameNS(W, 'drawing')]) if (isWatermark(drawing)) take(drawing);
+ for (const pict of [...doc.getElementsByTagNameNS(W, 'pict')]) if (readVmlWatermark(pict)) take(pict);
return removed;
}
/**
@@ -488,7 +496,11 @@ export function readWatermark(parts) {
for (const path of watermarkHeaders({ ...parts }, false)) {
const doc = readPart(parts, path);
const drawing = [...doc.getElementsByTagNameNS(W, 'drawing')].find(isWatermark);
- if (!drawing) continue;
+ if (!drawing) {
+ const word = wordWatermark(parts, path, doc);
+ if (word) return word;
+ continue;
+ }
const docPr = drawing.getElementsByTagNameNS(NS.wp, 'docPr')[0];
let watermark;
try { watermark = JSON.parse(docPr.getAttribute('descr') || 'null') || { kind: 'text' }; } catch { return { kind: 'text' }; }
@@ -501,6 +513,24 @@ export function readWatermark(parts) {
}
return null;
}
+/**
+ * A watermark Word wrote (VML in w:pict) in the terms of the Watermark dialog, or null. Word fits a text
+ * watermark to its box, so its size reads as Auto; a turned one is diagonal, and one Word made see-through
+ * is semitransparent. A picture watermark brings its picture (PNG or JPEG), size in pixels and washout.
+ */
+function wordWatermark(parts, path, doc) {
+ for (const pict of doc.getElementsByTagNameNS(W, 'pict')) {
+ const vml = readVmlWatermark(pict);
+ if (!vml) continue;
+ if (vml.kind === 'text') return { kind: 'text', text: vml.text, font: vml.font || null, size: 'auto', color: vml.color || 'C0C0C0', semitransparent: vml.opacity < 1, layout: Math.round(vml.rotation) % 180 ? 'diagonal' : 'horizontal' };
+ const media = relationshipTarget(parts, path, vml.imageRel);
+ let info = null;
+ try { info = media && parts[media] instanceof Uint8Array ? imageInfo(parts[media]) : null; } catch { info = null; }
+ const picture = { kind: 'picture', scale: 'auto', washout: vml.gain < 1 };
+ return info && ['image/png', 'image/jpeg'].includes(info.mime) ? { ...picture, bytes: parts[media], mime: info.mime, width: info.width, height: info.height } : picture;
+ }
+ return null;
+}
const EMU = 914400;
/** The writing area of the first section in EMU: { width, height }. */
function textArea(parts) {
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/src/engine/page-story-layout.js b/src/documents/src/engine/page-story-layout.js
index fe442c687..f5ee8e344 100644
--- a/src/documents/src/engine/page-story-layout.js
+++ b/src/documents/src/engine/page-story-layout.js
@@ -8,13 +8,16 @@ import { resolveStyledParagraph } from './styles.js';
import { shiftCellClip } from './cell-clipping.js';
import { referencePageGeometry } from './page-settings.js';
import { measureNote, FOOTNOTE_SEPARATOR } from './footnote-layout.js';
+import { watermarkFrame } from './vml-watermarks.js';
// Page stories have their own offsets and are kept outside body hit testing.
export function layoutPageStories(body, definition, sectionSettings, measure, numbering, { bestEffort = false, styles, drawingLayouts } = {}) {
const lines = [], borders = [], cells = [], tableBorders = [], floatingObjects = [], paragraphFrames = [], unsupported = [];
+ // Word watermarks (retained VML in a header or footer), placed on each page that shows their story.
+ const watermarks = [];
const borderMargins = [];
const positions = [];
- if (!definition) return { lines, borders, cells, tableBorders, floatingObjects, paragraphFrames, unsupported, borderMargins };
+ if (!definition) return { lines, borders, cells, tableBorders, floatingObjects, paragraphFrames, unsupported, borderMargins, watermarks };
const resolved = new Map(), storyKeys = new WeakMap();
// A measurer that remembers stories resolves their styles only when it measures one it has not seen.
const remembered = !!(styles && measure.paragraphCache);
@@ -41,6 +44,7 @@ export function layoutPageStories(body, definition, sectionSettings, measure, nu
const path = pageStoryPath(definition, section, kind, numbers[page], firstPages.get(section) === page);
const story = path && definition.stories[path];
if (!story?.paragraphs?.length) continue;
+ for (const p of story.paragraphs) for (const run of p.runs) if (run.vml?.watermark) watermarks.push({ page, pageStory: path, watermark: run.vml.watermark, ...watermarkFrame(run.vml.watermark, geometry) });
if (geometry.pageBorders?.offsetFrom === 'text') {
const excluded = kind === 'header' ? geometry.bordersDoNotSurroundHeader : geometry.bordersDoNotSurroundFooter;
const side = kind === 'header' ? 'top' : 'bottom';
@@ -108,5 +112,5 @@ export function layoutPageStories(body, definition, sectionSettings, measure, nu
}
}
}
- return { lines, borders, cells, tableBorders, floatingObjects, paragraphFrames, unsupported, borderMargins, positions };
+ return { lines, borders, cells, tableBorders, floatingObjects, paragraphFrames, unsupported, borderMargins, positions, watermarks };
}
diff --git a/src/documents/src/engine/quick-tables.js b/src/documents/src/engine/quick-tables.js
index 17f2990b5..e89608680 100644
--- a/src/documents/src/engine/quick-tables.js
+++ b/src/documents/src/engine/quick-tables.js
@@ -27,6 +27,14 @@ export const QUICK_TABLES = Object.freeze([
]);
/** Insert a quick table at the caret. `insertTable(rows, columns)` makes the empty table (it knows the writing width). */
+// Merging the cells of the table just inserted rearranges no one else's text: it runs with Track
+// Changes on too (table-editing refuses merges of existing cells then).
+function mergeNew(model) {
+ const internal = model.trackingInternal;
+ model.trackingInternal = true;
+ try { return mergeBlock(model); } finally { model.trackingInternal = internal; }
+}
+
export function insertQuickTable(model, id, insertTable, { date } = {}) {
const template = QUICK_TABLES.find(t => t.id === id);
if (!template) throw new Error('Choose a quick table.');
@@ -49,14 +57,14 @@ export function insertQuickTable(model, id, insertTable, { date } = {}) {
for (const r of spec.subheads || []) {
const row = table.rows[r];
model.cellBlock = { tableId: table.id, top: r, bottom: r, left: 0, right: columns };
- mergeBlock(model);
+ mergeNew(model);
const p = model.paragraphs.find(x => x.cellId === row.cells[0].id);
const start = model.paragraphOffset(model.paragraphs.indexOf(p));
model.select(start, start + paragraphText(p).length); model.setRunFormat({ bold: true, italic: true });
}
for (const [top, left, bottom, right] of spec.merge || []) {
model.cellBlock = { tableId: table.id, top, bottom, left, right };
- mergeBlock(model);
+ mergeNew(model);
const cell = rowGrid(table.rows[top])[0].cell;
model.selectCell(cell.id); model.setParagraphFormat({ alignment: 'center' });
}
diff --git a/src/documents/src/engine/revisions.js b/src/documents/src/engine/revisions.js
index 1dcfbf90b..15bbe9cf7 100644
--- a/src/documents/src/engine/revisions.js
+++ b/src/documents/src/engine/revisions.js
@@ -97,7 +97,7 @@ export function rejectFormatChange(target) {
return true;
}
// Fields that are content or annotations, not formatting.
-const KEEP = new Set(['id', 'text', 'runs', 'revision', 'formatRevision', 'link', 'contentControl', 'image', 'math', 'field', 'noteMark', 'bodySymbol', 'noBreakHyphen', 'breakClear', 'positionalTab', 'tocHyperlink', 'bookmarks', 'fieldMarkers', 'markRevision', 'cellId', 'tableStyleId', 'tableStyleRegions', 'blockControls', 'sectionEnd', 'sectionProperties', 'sectionSourceIndex', 'markFormat', 'markOverrides', 'markProperties', 'markPropertiesChanged', 'markHasDirectProperties', 'markStyleId', 'markStyleChanged', 'pageStory', 'storySource', 'drawingText']);
+const KEEP = new Set(['id', 'text', 'runs', 'revision', 'formatRevision', 'link', 'contentControl', 'image', 'math', 'vml', 'field', 'noteMark', 'bodySymbol', 'noBreakHyphen', 'breakClear', 'positionalTab', 'tocHyperlink', 'bookmarks', 'fieldMarkers', 'markRevision', 'cellId', 'tableStyleId', 'tableStyleRegions', 'blockControls', 'sectionEnd', 'sectionProperties', 'sectionSourceIndex', 'markFormat', 'markOverrides', 'markProperties', 'markPropertiesChanged', 'markHasDirectProperties', 'markStyleId', 'markStyleChanged', 'pageStory', 'storySource', 'drawingText']);
/** The formatting fields of a run or paragraph, for a formatting revision's `previous`. */
export function formattingSnapshot(target) {
@@ -120,7 +120,7 @@ function paragraphKey(p) {
const format = p.format || {};
return JSON.stringify([p.styleId || null, Object.keys(format).sort().map(key => [key, format[key]]), p.list ? [p.list.numId, p.list.level] : null]);
}
-const special = r => r.image || r.math || r.field || r.noteMark || r.bodySymbol || r.noBreakHyphen || r.breakClear || r.contentControl && !r.text;
+const special = r => r.image || r.math || r.vml || r.field || r.noteMark || r.bodySymbol || r.noBreakHyphen || r.breakClear || r.contentControl && !r.text;
function mergeRuns(runs) {
const out = [];
for (const run of runs) {
diff --git a/src/documents/src/engine/script-input.js b/src/documents/src/engine/script-input.js
index 3774ef5db..f77f2350f 100644
--- a/src/documents/src/engine/script-input.js
+++ b/src/documents/src/engine/script-input.js
@@ -47,7 +47,7 @@ function precedingComplexScript(text) {
export function typedScriptRuns(text, format, precedingText = '') {
// Ordinary text and inserted objects keep their existing path and metadata.
- if (!text || !(format.cs || format.rtl) || format.image || format.noteMark || format.bodySymbol) return [{ ...format, text }];
+ if (!text || !(format.cs || format.rtl) || format.image || format.vml || format.noteMark || format.bodySymbol) return [{ ...format, text }];
const chars = [...text], types = chars.map(c => ordinaryScript.test(c) ? 'L' : bidiCharacterType(c));
// The preceding complex script carries into leading neutral input,
// but a following ordinary LTR character in this batch takes those neutrals.
diff --git a/src/documents/src/engine/search.js b/src/documents/src/engine/search.js
index 0cbffdc9a..8b3125a9a 100644
--- a/src/documents/src/engine/search.js
+++ b/src/documents/src/engine/search.js
@@ -77,22 +77,37 @@ 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 [];
+ // 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);
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); }
+ // 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(text) : matchLiteral(text)) {
- if (start >= previousEnd && (simple || boundaries.has(start) && boundaries.has(end)) && (!wholeWord || wordStarts.has(start) && wordEnds.has(end)) && !overlaps(deleted, start, end)) {
+ for (const { start, end } of simple ? matchSimple(searched) : matchLiteral(searched)) {
+ 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;
}
}
@@ -125,7 +140,7 @@ export function replacedRuns(runs, matches, replacement) {
if (match.replacementRuns) result.push(...match.replacementRuns);
else if (replacement) {
const run = { ...(removed[0].contentControl?.placeholder ? removed[0].contentControl.inputRun : removed[0]), text: replacement };
- delete run.noteMark; delete run.bodySymbol; delete run.noBreakHyphen;
+ delete run.noteMark; delete run.bodySymbol; delete run.noBreakHyphen; delete run.vml;
// New text is nobody's tracked insertion, deletion or formatting change, whatever the found text was.
delete run.revision; delete run.formatRevision;
if (match.control) run.contentControl = match.control;
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/engine/story-session.js b/src/documents/src/engine/story-session.js
index 0b7cea2db..ee902805d 100644
--- a/src/documents/src/engine/story-session.js
+++ b/src/documents/src/engine/story-session.js
@@ -35,7 +35,9 @@ export class StorySession {
this.path = path;
return this.model;
}
- capture() { return { path: this.path, page: this.page, state: this.snapshot(this.model), revision: this.model.revision }; }
+ // `caret()`, when the editor sets it, says where its caret shows at a line's end (its affinity), which a
+ // position alone does not; Undo and Redo put it back with the selection.
+ capture() { return { path: this.path, page: this.page, state: this.snapshot(this.model), revision: this.model.revision, caret: this.caret?.() }; }
/**
* The model's state for undo. Copying a long document takes a while, so after each command the
* document is copied while the browser is idle (prepareSnapshot); the next command starts from
diff --git a/src/documents/src/engine/strikethrough.js b/src/documents/src/engine/strikethrough.js
index 3e2f1a25f..060ddce67 100644
--- a/src/documents/src/engine/strikethrough.js
+++ b/src/documents/src/engine/strikethrough.js
@@ -1,8 +1,12 @@
import { textFillColor } from './text-fill.js';
import { UnsupportedDocumentError } from './errors.js';
+// A property set can declare both forms when at most one is on (§17.3.2.37 strike, §17.3.2.9 dstrike):
+// both off, or one on with the other off. Each pair resolves its two declarations independently.
+const combined = { bothStrikeOff: ['singleOff', 'doubleOff'], singleStrikeDoubleOff: ['single', 'doubleOff'], doubleStrikeSingleOff: ['singleOff', 'double'] };
+export const strikeDeclarations = value => combined[value] || [value];
export function checkedStrike(value, declaration = false) {
- if (!(declaration ? ['single', 'double', 'singleOff', 'doubleOff'] : ['none', 'single', 'double']).includes(value)) throw new UnsupportedDocumentError('invalid strikethrough value');
+ if (!(declaration ? ['single', 'double', 'singleOff', 'doubleOff', ...Object.keys(combined)] : ['none', 'single', 'double']).includes(value)) throw new UnsupportedDocumentError('invalid strikethrough value');
return value;
}
@@ -12,6 +16,10 @@ export function checkedStrike(value, declaration = false) {
export function resolveStrike(inherited = 'none', declaration, toggle = false, defaultStrike = 'none') {
if (declaration === undefined) return inherited;
if (declaration === 'none') return 'none';
+ if (combined[declaration]) {
+ const [single, double] = combined[declaration].map(value => resolveStrike(inherited, value, toggle, defaultStrike));
+ return double === 'double' ? 'double' : single === 'single' ? 'single' : 'none';
+ }
checkedStrike(declaration, true);
const single = declaration.startsWith('single'), enabled = !declaration.endsWith('Off');
const kind = single ? 'single' : 'double';
diff --git a/src/documents/src/engine/styles.js b/src/documents/src/engine/styles.js
index b079584e8..cce928aa3 100644
--- a/src/documents/src/engine/styles.js
+++ b/src/documents/src/engine/styles.js
@@ -4,7 +4,7 @@ import { FONT_DEFAULTS, FONT_SLOTS, checkedFontEdit } from './font-families.js';
import { BORDER_SIDES, checkedParagraphBorders } from './paragraph-borders.js';
import { checkedUnderline, underlineDeclaration } from './underlines.js';
import { checkedCapitalization, resolveCapitalization, checkCapitalizationText, capitalizationDeclarations } from './capitalization.js';
-import { checkedStrike, resolveStrike } from './strikethrough.js';
+import { checkedStrike, resolveStrike, strikeDeclarations } from './strikethrough.js';
import { checkedLigatures } from './ligatures.js';
import { checkedKerning } from './kerning.js';
import { checkedTextScale } from './text-scale.js';
@@ -66,8 +66,11 @@ export function styleRun(styles, id, inherited = styles.defaults.run, type = 'pa
// Find the nearest declaration of each form; an off declaration of one
// form must not erase a different form inherited through basedOn.
const strikes = new Map();
- chain.forEach((s, index) => { if (s.run.strikethrough !== undefined) { checkedStrike(s.run.strikethrough, true); strikes.set(s.run.strikethrough.startsWith('double') ? 'double' : 'single', { index, value: s.run.strikethrough }); } });
- result.strikethrough = [...strikes.values()].sort((a,b) => a.index - b.index).reduce((mode, declaration) => resolveStrike(mode, declaration.value, type === 'character', resolveStrike('none', styles.defaults.run.strikethrough)), resolveStrike('none', inherited.strikethrough));
+ chain.forEach((s, index) => { if (s.run.strikethrough !== undefined) { checkedStrike(s.run.strikethrough, true); for (const value of strikeDeclarations(s.run.strikethrough)) strikes.set(value.startsWith('double') ? 'double' : 'single', { index, value }); } });
+ const strikeOrder = [...strikes.values()].sort((a,b) => a.index - b.index);
+ // Keep a pair declared at the same level simultaneous, as capitalization does.
+ if (strikeOrder.length === 2 && strikeOrder[0].index === strikeOrder[1].index) strikeOrder.splice(0, 2, { value: chain[strikeOrder[0].index].run.strikethrough });
+ result.strikethrough = strikeOrder.reduce((mode, declaration) => resolveStrike(mode, declaration.value, type === 'character', resolveStrike('none', styles.defaults.run.strikethrough)), resolveStrike('none', inherited.strikethrough));
const capitals = new Map();
chain.forEach((s, index) => { if (s.run.capitalization !== undefined) {
checkedCapitalization(s.run.capitalization, true);
@@ -123,6 +126,11 @@ export function paragraphRunStyle(p, styles) {
const table = p.cellId ? styleRun(styles, p.tableStyleId, defaults, 'table', p.tableStyleRegions) : defaults;
return styleRun(styles, effectiveStyleId(p, styles), table);
}
+/**
+ * The character unit of character-unit indents (§17.3.1.12), in twips: the East Asian font size of the
+ * Normal style, as Word measures it (a full-width character is one em wide). Evaluated only when needed.
+ */
+export const characterUnit = styles => () => (styleRun(styles, styles.defaultParagraph).fontSize || 12) * 20;
export function resolveStyledParagraph(p, styles, numbering, options) {
const tableChain = p.cellId ? withTableRegions(styleChain(styles, p.tableStyleId, 'table'), p.tableStyleRegions) : [];
const base = tableChain.reduce((properties, s) => mergeProperties(properties, s.properties), styles.defaults.properties);
@@ -140,7 +148,7 @@ export function resolveStyledParagraph(p, styles, numbering, options) {
const inheritedGeometry = cancelledIndent(inherited, context.reference);
const properties = mergeProperties(inheritedGeometry, context.properties);
p.frameProperties = properties.frameProperties;
- const resolved = resolvedFormat(properties);
+ const unit = characterUnit(styles), resolved = resolvedFormat(properties, unit);
p.format = { ...resolved, ...p.formatOverrides, paragraphBorders: { ...resolved.paragraphBorders, ...p.formatOverrides?.paragraphBorders } };
clearAutomaticSpacing(p.format, p.formatOverrides);
// MS-OI29500 §2.1.51: a built-in heading's ID fixes its effective outline level.
@@ -157,10 +165,10 @@ export function resolveStyledParagraph(p, styles, numbering, options) {
// its descendants still override the numbering level.
const numberingIndex = chain.findLastIndex(s => s.reference?.numId !== undefined);
const split = Math.max(0, numberingIndex);
- const chainGeometry = entries => geometryPatch(entries.reduce((properties, s) => mergeProperties(properties, s.properties), {}));
+ const chainGeometry = entries => geometryPatch(entries.reduce((properties, s) => mergeProperties(properties, s.properties), {}), false, unit);
// Numbering levels' other paragraph properties resolve against the same three layers.
const paragraph = { base: levelParagraphPatch(base), style: levelParagraphPatch(chain.reduce((properties, s) => mergeProperties(properties, s.properties), {})), direct: levelParagraphPatch(context.properties) };
- p.listContext = { base: geometryPatch(base), style: chainGeometry(chain), afterNumbering: chainGeometry(chain.slice(split)), direct: geometryPatch(context.properties), paragraph };
+ p.listContext = { base: geometryPatch(base, false, unit), style: chainGeometry(chain), afterNumbering: chainGeometry(chain.slice(split)), direct: geometryPatch(context.properties, false, unit), paragraph };
if (!p.listChanged) {
const inheritedReference = Object.assign({}, styles.defaults.reference, ...tableChain.map(s => s.reference), ...chain.map(s => s.reference));
const ref = { ...inheritedReference, ...context.reference };
@@ -243,8 +251,9 @@ export function editStyleProperties(properties, patch) {
set(p.spacing, spacingKeys[key], key === 'lineSpacing' ? line && String(Math.round(line.lineSpacing * (lineRule === 'auto' ? 240 : 20))) : key === 'lineRule' ? value : String(Math.round(value * 20)));
if (['spaceBefore', 'spaceAfter'].includes(key)) for (const suffix of ['Lines', 'Autospacing']) set(p.spacing, spacingKeys[key] + suffix, '0');
}
- else if (key in indentKeys) set(p.ind, indentKeys[key]);
- else if (key === 'firstLine') { delete p.ind.firstLine; delete p.ind.hanging; if (value !== null) p.ind[value < 0 ? 'hanging' : 'firstLine'] = Math.abs(value); }
+ // An edited indent is in twips; its character-unit form (§17.3.1.12) is zeroed so an inherited one cannot override it.
+ else if (key in indentKeys) { set(p.ind, indentKeys[key]); set(p.ind, indentKeys[key] + 'Chars', 0); }
+ else if (key === 'firstLine') { for (const name of ['firstLine', 'hanging', 'firstLineChars', 'hangingChars']) delete p.ind[name]; if (value !== null) { p.ind[value < 0 ? 'hanging' : 'firstLine'] = Math.abs(value); p.ind[value < 0 ? 'hangingChars' : 'firstLineChars'] = 0; } }
else if (key === 'tabs') { if (value === null) { delete p.tabs; delete p.tabChanges; } else { p.tabs = value; p.tabChanges = value; } }
else set(p, key, key === 'paragraphShading' && value !== null ? checkedShading(value) : value);
}
@@ -282,7 +291,7 @@ export function editedStyle(styles, id, patch, type = 'paragraph') {
next.changes.paragraph.paragraphBorders = incoming === null ? null : { ...(previousBorders === null ? Object.fromEntries(BORDER_SIDES.map(side => [side, null])) : previousBorders), ...checkedParagraphBorders(incoming, true) };
}
const candidate = { ...styles, definitions: { ...styles.definitions, [id]: next } };
- if (type === 'paragraph') resolvedFormat(styleProperties(candidate, id));
+ if (type === 'paragraph') resolvedFormat(styleProperties(candidate, id), characterUnit(candidate));
checkedRunFormat(styleRun(candidate, id, candidate.defaults.run, type));
return next;
}
diff --git a/src/documents/src/engine/table-editing.js b/src/documents/src/engine/table-editing.js
index 031b56cac..05ce9c843 100644
--- a/src/documents/src/engine/table-editing.js
+++ b/src/documents/src/engine/table-editing.js
@@ -6,12 +6,13 @@
// 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';
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';
@@ -50,6 +51,15 @@ export function selectionBlock(model) {
return null;
}
+/**
+ * Table edits that remove or rearrange text (rows, columns, cells, a whole table, merging, sorting,
+ * converting) are not recorded as tracked changes, so with Track Changes on they refuse to run rather
+ * than change text a reviewer would never see, as sorting paragraphs does. `what` names the command.
+ */
+export function refuseUntracked(model, what) {
+ if (model.trackChanges && !model.trackingInternal) throw new Error(`${what} cannot be tracked yet. Turn off Track Changes to do this.`);
+}
+
function requireBlock(model) {
const block = selectionBlock(model);
if (!block) throw new Error('Put the cursor in a table first.');
@@ -107,7 +117,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 +147,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.');
@@ -180,6 +190,7 @@ export function insertColumns(model, { left = false, count = null, widths: measu
}
function removeTable(model, table) {
+ refuseUntracked(model, 'Deleting a table');
const state = M.tableContent(model, table);
model.checkpoint();
model.tables = model.tables.filter(t => t.id !== table.id);
@@ -198,7 +209,8 @@ 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;
+ refuseUntracked(model, 'Deleting table rows');
+ 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 +236,8 @@ 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;
+ refuseUntracked(model, 'Deleting table columns');
+ 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;
@@ -270,6 +283,7 @@ function requireUnmerged(table, rows, left, right, message) {
/** Delete Cells: shift the cells to the right of the selection left, or the cells below it up. */
export function deleteCells(model, { shift = 'left' } = {}) {
+ refuseUntracked(model, 'Deleting table cells');
const block = requireBlock(model), table = block.table;
if (shift === 'row') return deleteRows(model);
if (shift === 'column') return deleteColumns(model);
@@ -390,6 +404,7 @@ export function insertCells(model, { shift = 'down', widths: measured } = {}) {
/** Merge the selected rectangle of cells into one, keeping each non-empty cell's paragraphs. */
export function mergeBlock(model) {
+ refuseUntracked(model, 'Merging cells');
const block = requireBlock(model), table = block.table, placements = cellBlockPlacements(table, block);
if (placements.length < 2) throw new Error('Select two or more cells to merge.');
requireAligned(table, block);
@@ -568,11 +583,20 @@ 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.');
+ // 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.
+ 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;
}
@@ -583,27 +607,35 @@ 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;
}
- 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 });
}
-/** 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') {
- 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. */
@@ -879,6 +911,7 @@ export function sortDate(text) {
* cannot read sort after the others, in either order.
*/
export function sortTable(model, { keys, header = false, caseSensitive = false } = {}) {
+ refuseUntracked(model, 'Sorting table rows');
if (!Array.isArray(keys) || !keys.length || keys.length > 3) throw new Error('Choose one to three sort keys.');
const block = requireBlock(model), table = block.table;
let [first, last] = block.bottom > block.top ? [block.top, block.bottom] : [0, table.rows.length - 1];
@@ -889,7 +922,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 => {
@@ -907,6 +940,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
@@ -922,7 +964,7 @@ export function sortTable(model, { keys, header = false, caseSensitive = false }
const SEPARATORS = { tab: '\t', comma: ',', paragraph: null };
// Run properties that make a run an object or a marked change rather than plain formatted text.
-const OBJECT_RUN_KEYS = ['image', 'math', 'field', 'noteMark', 'contentControl', 'bodySymbol', 'positionalTab', 'noBreakHyphen', 'breakClear', 'revision', 'formatRevision', 'link'];
+const OBJECT_RUN_KEYS = ['image', 'math', 'vml', 'field', 'noteMark', 'contentControl', 'bodySymbol', 'positionalTab', 'noBreakHyphen', 'breakClear', 'revision', 'formatRevision', 'link'];
function separatorChar(separator, other) {
if (separator === 'other') { if (typeof other !== 'string' || [...other].length !== 1 || /[\n\v\f]/.test(other)) throw new Error('Type one separator character.'); return other; }
if (!(separator in SEPARATORS)) throw new Error('Choose how the text is separated.');
@@ -978,8 +1020,28 @@ 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 } = {}) {
+ refuseUntracked(model, 'Converting text to a table');
const char = separatorChar(separator, other), sources = model.selectedParagraphs;
if (!sources.length || sources.every(p => !paragraphText(p)) && sources.length === 1) throw new Error('Select the text to convert.');
// Text in one table cell becomes a table nested in that cell.
@@ -1012,7 +1074,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;
@@ -1020,6 +1084,7 @@ export function convertTextToTable(model, { separator = 'tab', other, columns =
/** Convert Table to Text: each row becomes a paragraph (cells separated), or each cell its paragraphs. */
export function convertTableToText(model, { separator = 'tab', other } = {}) {
+ refuseUntracked(model, 'Converting a table to text');
const char = separatorChar(separator, other), table = requireBlock(model).table;
const ids = new Set(tableCells(table).map(cell => cell.id));
if (model.tables.some(t => ids.has(t.parentCellId))) throw new Error('Convert the tables inside this table first.');
@@ -1037,9 +1102,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) => {
@@ -1049,7 +1115,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;
}
@@ -1134,6 +1200,7 @@ export function drawLine(model, { tableId: id, orientation, cellIds, offset, wid
/** Eraser: remove the line between two cells, merging them when they line up. */
export function eraseLine(model, { tableId: id, cellIds }) {
+ refuseUntracked(model, 'Erasing a line between cells');
const table = model.tables.find(t => t.id === id);
if (!table || cellIds?.length !== 2) throw new Error('Erase a line between two cells.');
const block = blockBetween(table, cellIds[0], cellIds[1]);
@@ -1183,8 +1250,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/src/engine/table-formula.js b/src/documents/src/engine/table-formula.js
index 366b821d5..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;
}
@@ -105,9 +106,20 @@ export const columnName = column => { let name = ''; for (let n = column + 1; n
/* ---- Evaluation ---- */
-/** The text a cell shows (its own paragraphs, not nested tables'). */
+// 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. */
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];
@@ -198,7 +210,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/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/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/src/engine/vml-watermarks.js b/src/documents/src/engine/vml-watermarks.js
new file mode 100644
index 000000000..f91b6727e
--- /dev/null
+++ b/src/documents/src/engine/vml-watermarks.js
@@ -0,0 +1,115 @@
+// Word's watermarks (Design > Watermark) are legacy VML shapes in a header paragraph (ECMA-376 Part 4
+// §14.1 VML; Part 1 §17.3.3.19 w:pict): a WordArt text path for a text watermark, or a picture
+// (v:imagedata, washed out with gain and black level) for a picture watermark, positioned absolutely
+// behind the text, usually centred on the margins. The editor keeps such a w:pict as one retained
+// object (its XML saved unchanged) and draws what it describes behind the page's text.
+const V = 'urn:schemas-microsoft-com:vml', O = 'urn:schemas-microsoft-com:office:office', R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
+const elements = node => [...(node?.children || [])];
+const PT = 12700; // EMU per point
+
+/** CSS-like `name:value;` pairs of a VML style attribute. */
+export function vmlStyle(text = '') {
+ const out = {};
+ for (const part of String(text).split(';')) {
+ const at = part.indexOf(':'); if (at < 0) continue;
+ const name = part.slice(0, at).trim().toLowerCase(), value = part.slice(at + 1).trim();
+ if (name) out[name] = value;
+ }
+ return out;
+}
+/** A VML length (pt, in, cm, mm, px, pc or a bare number of pixels) in EMU, or null. */
+export function vmlLength(value) {
+ const m = /^(-?\d*\.?\d+)\s*(pt|in|cm|mm|px|pc|emu)?$/i.exec(String(value || '').trim());
+ if (!m) return null;
+ const n = Number(m[1]), unit = (m[2] || 'px').toLowerCase();
+ return Math.round(n * { pt: PT, in: 914400, cm: 360000, mm: 36000, px: 9525, pc: 12 * PT, emu: 1 }[unit]);
+}
+// VML colours: #RRGGBB, #RGB, or the named colours Word writes for watermarks.
+const NAMED = { silver: 'C0C0C0', gray: '808080', grey: '808080', black: '000000', white: 'FFFFFF', red: 'FF0000', green: '008000', blue: '0000FF', yellow: 'FFFF00', navy: '000080', maroon: '800000', teal: '008080', olive: '808000', purple: '800080', aqua: '00FFFF', fuchsia: 'FF00FF', lime: '00FF00' };
+export function vmlColor(value) {
+ const text = String(value || '').trim().split(/\s/)[0].toLowerCase();
+ if (/^#[0-9a-f]{6}$/.test(text)) return text.slice(1).toUpperCase();
+ if (/^#[0-9a-f]{3}$/.test(text)) return [...text.slice(1)].map(c => c + c).join('').toUpperCase();
+ return NAMED[text] || null;
+}
+/** A VML fraction ("0.5", ".5", "32768f" in 1/65536ths), or null. */
+export function vmlFraction(value) {
+ const text = String(value ?? '').trim();
+ if (/^-?\d+f$/i.test(text)) return Number(text.slice(0, -1)) / 65536;
+ return /^-?\d*\.?\d+$/.test(text) ? Number(text) : null;
+}
+
+/**
+ * The watermark a w:pict holds, or null when it is not one: one v:shape (beside an optional
+ * v:shapetype), absolutely positioned, that Word names as a watermark, with a text path or picture.
+ * { kind: 'text' | 'picture', text, font, color, opacity, imageRel, gain, blackLevel, width, height,
+ * rotation (degrees), horizontal: { align, relativeFrom, offset }, vertical: { … }, behind }.
+ */
+export function readVmlWatermark(pict) {
+ const kids = elements(pict);
+ if (!kids.length || kids.some(n => n.namespaceURI !== V || !['shapetype', 'shape'].includes(n.localName))) return null;
+ const shapes = kids.filter(n => n.localName === 'shape');
+ if (shapes.length !== 1) return null;
+ const shape = shapes[0], id = shape.getAttribute('id') || '', spid = shape.getAttributeNS(O, 'spid') || '';
+ if (!/^(PowerPlusWaterMarkObject|PowerPlusPictureWaterMarkObject|WordPictureWatermark)/.test(id) && !/WaterMark/i.test(spid)) return null;
+ const style = vmlStyle(shape.getAttribute('style'));
+ if (style.position !== 'absolute') return null;
+ const width = vmlLength(style.width), height = vmlLength(style.height);
+ if (!(width > 0) || !(height > 0)) return null;
+ const child = name => elements(shape).find(n => n.namespaceURI === V && n.localName === name) || null;
+ const textpath = child('textpath'), imagedata = child('imagedata'), fill = child('fill');
+ const axis = (key, relativeKey, offsetKey) => ({ align: style[key] || null, relativeFrom: style[relativeKey] || (key.endsWith('horizontal') ? 'column' : 'paragraph'), offset: vmlLength(style[offsetKey]) || 0 });
+ const common = {
+ width, height, rotation: Number.parseFloat(style.rotation) || 0, behind: Number.parseInt(style['z-index'], 10) < 0,
+ horizontal: axis('mso-position-horizontal', 'mso-position-horizontal-relative', 'margin-left'),
+ vertical: axis('mso-position-vertical', 'mso-position-vertical-relative', 'margin-top'),
+ };
+ if (textpath && !imagedata) {
+ const text = textpath.getAttribute('string') || '';
+ if (!text.trim()) return null;
+ const font = vmlStyle(textpath.getAttribute('style'))['font-family']?.replace(/^["']|["']$/g, '').replace(/"/g, '') || 'Calibri';
+ const filled = shape.getAttribute('filled') !== 'f' && shape.getAttribute('filled') !== 'false';
+ return { kind: 'text', text, font, color: filled ? vmlColor(shape.getAttribute('fillcolor')) || 'C0C0C0' : null, opacity: Math.max(0, Math.min(1, vmlFraction(fill?.getAttribute('opacity')) ?? 1)), ...common };
+ }
+ if (imagedata && !textpath) {
+ const imageRel = imagedata.getAttributeNS(R, 'id') || imagedata.getAttributeNS(R, 'pict') || '';
+ if (!imageRel) return null;
+ return { kind: 'picture', imageRel, gain: vmlFraction(imagedata.getAttribute('gain')) ?? 1, blackLevel: vmlFraction(imagedata.getAttribute('blacklevel')) ?? 0, ...common };
+ }
+ return null;
+}
+
+/**
+ * The package part a picture watermark's v:imagedata names, resolved through its story's
+ * relationships, or null when it is missing, external or not an image relationship.
+ */
+export function watermarkImagePart(parts, sourcePart, relId, { parseXml, relationshipPartPath, partPathForUrl }) {
+ try {
+ const relsPath = relationshipPartPath(sourcePart);
+ if (!parts[relsPath]) return null;
+ const rels = parseXml(parts[relsPath]), matches = [...rels.getElementsByTagNameNS('*', 'Relationship')].filter(n => n.getAttribute('Id') === relId);
+ if (matches.length !== 1 || !/\/image$/.test(matches[0].getAttribute('Type') || '') || (matches[0].getAttribute('TargetMode') || 'Internal') !== 'Internal') return null;
+ const url = new URL(matches[0].getAttribute('Target'), `https://docx.invalid/${sourcePart}`);
+ if (url.origin !== 'https://docx.invalid' || url.search || url.hash) return null;
+ const path = partPathForUrl(parts, url);
+ return parts[path] ? path : null;
+ } catch { return null; }
+}
+
+/**
+ * Where a watermark sits on a page (CSS pixels): its box, before its rotation about the box's centre.
+ * Positions relative to the page, or else to the margins (Word's own watermarks are centred on them).
+ */
+export function watermarkFrame(watermark, page) {
+ const px = emu => emu / 9525, width = px(watermark.width), height = px(watermark.height);
+ const place = (axis, size) => {
+ const { align, relativeFrom, offset } = watermark[axis], horizontal = axis === 'horizontal';
+ const extent = horizontal ? page.width : page.height;
+ const [start, end] = relativeFrom === 'page' ? [0, extent] : horizontal ? [page.left, page.width - page.right] : [page.top, page.height - page.bottom];
+ if (align === 'center') return start + (end - start - size) / 2;
+ if (align === (horizontal ? 'right' : 'bottom') || align === 'outside') return end - size;
+ if (align === (horizontal ? 'left' : 'top') || align === 'inside') return start;
+ return start + px(offset);
+ };
+ return { x: place('horizontal', width), y: place('vertical', height), width, height };
+}
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/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/src/lib/document-export.js b/src/documents/src/lib/document-export.js
index f9bda1753..8f5f71f6d 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) {
@@ -113,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;
@@ -276,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(' ')];
@@ -294,12 +315,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/src/lib/importers.js b/src/documents/src/lib/importers.js
index 9b5e0ca32..9abbf7f5c 100644
--- a/src/documents/src/lib/importers.js
+++ b/src/documents/src/lib/importers.js
@@ -49,6 +49,7 @@ export function htmlBody(html, { preserveFallback = false } = {}) {
const parsed = new DOMParser().parseFromString(src, 'text/html');
// Formatting a page gives through its style sheet goes before the style sheet does.
if (parsed.body && parsed.styleSheets?.length) inlineSheetFormatting(parsed);
+ if (parsed.body) { keepBlankLines(parsed.body); nestClassedLists(parsed.body); }
// Markup the converter reads poorly (definition lists, captions, checkboxes, , , hidden content) takes a
// form it reads well, before the form controls it would otherwise stand in are dropped with the rest of the non-content.
if (parsed.body) normalizeMarkup(parsed.body);
@@ -71,6 +72,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.
@@ -82,8 +91,48 @@ export function htmlBody(html, { preserveFallback = false } = {}) {
return parsed.body ? parsed.body.innerHTML : src;
}
-/** The style-sheet properties carried onto elements: emphasis, decoration, script position and alignment. */
-const SHEET_PROPERTIES = ['font-weight', 'font-style', 'text-decoration-line', 'vertical-align', 'text-align'];
+/** The style-sheet properties carried onto elements: emphasis, decoration, script position, alignment and height. */
+const SHEET_PROPERTIES = ['font-weight', 'font-style', 'text-decoration-line', 'vertical-align', 'text-align', 'height'];
+
+/**
+ * An empty paragraph the page gives a height is a blank line (Google Docs writes one as
,
+ * the class giving it a line's height): it gets a line break, so it is not taken for markup around nothing.
+ */
+function keepBlankLines(body) {
+ for (const p of body.querySelectorAll('p')) {
+ if (p.textContent.trim() || p.querySelector('img, br, table, ul, ol')) continue;
+ if (Number.parseFloat(p.style?.height) > 0) p.append(p.ownerDocument.createElement('br'));
+ }
+}
+
+/**
+ * Lists a page nests by class rather than by markup, as Google Docs writes them: every level is a list of its own
+ * (class lst-kix_-), one after another, and the style sheet indents them and numbers them on. Each deeper
+ * one goes into the last item of the list before it a level up, and a list that goes on from one of the same list and
+ * level (without "start") becomes part of it, so the levels nest and the numbers run on as on the page.
+ */
+function nestClassedLists(body) {
+ const kix = (el) => /\blst-kix_(\S+)-(\d+)\b/.exec(el.className || '');
+ if (!body.querySelector('ul[class*="lst-kix_"], ol[class*="lst-kix_"]')) return;
+ for (const parent of new Set([...body.querySelectorAll('ul[class*="lst-kix_"], ol[class*="lst-kix_"]')].map((list) => list.parentElement))) {
+ let stack = [];
+ for (const el of [...parent.children]) {
+ const match = /^(UL|OL)$/.test(el.tagName) ? kix(el) : null;
+ if (!match) { stack = []; continue; }
+ const [, id, levelText] = match, level = Math.min(8, Number(levelText));
+ const goesOn = !el.classList.contains('start');
+ const same = stack[level];
+ if (same && same.id === id && same.list.tagName === el.tagName && goesOn) {
+ same.list.append(...el.children);
+ el.remove();
+ } else if (level > 0 && stack[level - 1]) {
+ stack[level - 1].list.lastElementChild?.append(el);
+ stack[level] = { id, list: el };
+ } else stack[level] = { id, list: el };
+ stack.length = level + 1;
+ }
+ }
+}
/**
* Write the formatting a page's style sheets give its elements into their style attributes, where the
@@ -142,6 +191,8 @@ export function decodeTextFile(bytes, kind = null) {
const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
if (b[0] === 0xff && b[1] === 0xfe) return new TextDecoder('utf-16le').decode(b);
if (b[0] === 0xfe && b[1] === 0xff) return new TextDecoder('utf-16be').decode(b);
+ const unmarked = unmarkedUtf16(b);
+ if (unmarked) return unmarked;
try { return new TextDecoder('utf-8', { fatal: true }).decode(b); } catch { /* not UTF-8 throughout */ }
const declared = kind === 'html' ? declaredCharset(b) : null;
// A UTF-16 label on a byte-oriented page is stale (the bytes were not UTF-16, or the BOM above would have said so): browsers read such a page as UTF-8.
@@ -159,9 +210,71 @@ 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;
+}
+
+/**
+ * UTF-16 without a byte-order mark (some editors and tools write it so): text has spaces, digits and line ends, whose
+ * second byte (little-endian) or first (big-endian) is zero, and a text in any 8-bit encoding has no zero bytes. A
+ * file whose zero bytes fall on one side of the pairs, and that reads as UTF-16 throughout, is read as it; null else.
+ */
+function unmarkedUtf16(b) {
+ if (b.length < 4 || b.length % 2) return null;
+ const n = Math.min(b.length, 8192) & ~1;
+ let even = 0, odd = 0;
+ for (let i = 0; i < n; i += 2) { if (!b[i]) even++; if (!b[i + 1]) odd++; }
+ const pairs = n / 2, zeros = even + odd;
+ if (zeros < 2) return null;
+ const encoding = odd >= zeros * 0.9 && odd >= pairs * 0.05 ? 'utf-16le' : even >= zeros * 0.9 && even >= pairs * 0.05 ? 'utf-16be' : null;
+ if (!encoding) return null;
+ let text;
+ try { text = new TextDecoder(encoding, { fatal: true }).decode(b); } catch { return null; }
+ // Read as UTF-16, text is letters, not control characters.
+ return (text.match(/[\x00-\x08\x0e-\x1f]/g) || []).length <= text.length * 0.01 ? 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/src/lib/markdown-html.js b/src/documents/src/lib/markdown-html.js
index 1284f958f..53f061511 100644
--- a/src/documents/src/lib/markdown-html.js
+++ b/src/documents/src/lib/markdown-html.js
@@ -3,16 +3,20 @@ import { gfmFromMarkdown } from 'mdast-util-gfm';
import { gfm } from 'micromark-extension-gfm';
import { toHast } from 'mdast-util-to-hast';
import { toHtml } from 'hast-util-to-html';
+import { htmlBody } from './importers.js';
/**
* Markdown (with GitHub tables, task lists and strikethrough) as HTML. Raw HTML in the source (a centered
- * paragraph, ,
) is kept: the only reader is htmlFragment, which parses it inertly, keeps its
- * text and formatting, and skips scripts, styles and embedded content as it does for pasted web pages.
+ * paragraph, ,
) is kept, and read as a web page's body is (htmlBody): its text and formatting,
+ * without hidden content, scripts, styles, forms or embedded content. The only reader is htmlFragment, which
+ * parses it inertly.
*/
export function markdownHtml(source) {
try {
- const tree = fromMarkdown(String(source ?? ''), { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] });
- return toHtml(withoutBackReferences(toHast(tree, { allowDangerousHtml: true })), { allowDangerousHtml: true });
+ const text = String(source ?? '');
+ const tree = fromMarkdown(text, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] });
+ const html = toHtml(withoutBackReferences(toHast(tree, { allowDangerousHtml: true })), { allowDangerousHtml: true });
+ return /<[a-z!/?]/i.test(text) ? htmlBody(html) : html;
} catch (e) {
// Structure nested thousands deep (quotes in quotes, emphasis in emphasis) overflows the converters'
// recursion. The file's text still opens, one paragraph per line, as it would from a .txt file.
diff --git a/src/documents/src/lib/pdf-export.js b/src/documents/src/lib/pdf-export.js
index f1e378db0..d0934719a 100644
--- a/src/documents/src/lib/pdf-export.js
+++ b/src/documents/src/lib/pdf-export.js
@@ -6,8 +6,13 @@
* and whose ToUnicode map returns the original characters.
*/
+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;
+/** 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) {
@@ -35,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));
@@ -100,13 +123,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)) });
@@ -137,7 +168,36 @@ export function pageFragments(lines, page) {
const out = [];
for (const line of lines) {
if (line.page !== page || line.virtual || line.cellText) continue;
- 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 });
+ // 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, ...(f.bidiDirection === 'rtl' ? { rtl: true } : {}) });
+ }
+ 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;
}
@@ -152,9 +212,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/src/lib/spelling.js b/src/documents/src/lib/spelling.js
index c3e2252aa..7213f98fa 100644
--- a/src/documents/src/lib/spelling.js
+++ b/src/documents/src/lib/spelling.js
@@ -35,6 +35,7 @@ export function proofingText(text, paragraphs) {
* The words spelling checks: [{ start, end, word, wordStart?, wordEnd? }]. Hyphenated compounds are checked part by part;
* apostrophes inside a word stay part of it and typographic ones are normalized.
*/
+const INVISIBLE = /[\u00ad\u200b-\u200d\u2060\ufeff]/;
export function wordsToCheck(text) {
const words = [];
const segmenter = new Intl.Segmenter('en', { granularity: 'word' });
@@ -49,7 +50,16 @@ export function wordsToCheck(text) {
const after = text.slice(end).search(/[\s]/);
const token = text.slice(before, after < 0 ? text.length : end + after).replace(/[\s\u000b\f\n]+/g, '');
if (ADDRESS.test(token.replace(/[.,;:!?)"'’”]+$/, ''))) continue;
- const word = segment.segment.replace(/’/g, "'");
+ // Optional hyphens (manual hyphenation) and zero-width joiners and spaces are not letters: the word is checked
+ // without them. `source[i]` is where the checked word's character i is in the segment.
+ const source = [];
+ let word = '';
+ for (let i = 0; i < segment.segment.length; i++) {
+ const c = segment.segment[i];
+ if (INVISIBLE.test(c)) continue;
+ word += c === '’' ? "'" : c; source.push(i);
+ }
+ if (!word) continue;
if (/\d/.test(word) || /_/.test(word)) continue;
if (word.length > 1 && word === word.toUpperCase() && /\p{Lu}/u.test(word)) continue;
// The only dictionary is English: a word with no Latin letter (Thai, Cyrillic, Han...) is not checked.
@@ -58,8 +68,8 @@ export function wordsToCheck(text) {
// than the segment, wordStart and wordEnd say where it is, so a correction keeps the "'s" and apostrophes.
const trimmed = word.replace(/'s$/i, '').replace(/^'+|'+$/g, '');
if (!trimmed || trimmed === word) { words.push({ start, end, word: trimmed || word }); continue; }
- const wordStart = start + word.match(/^'*/)[0].length;
- words.push({ start, end, word: trimmed, wordStart, wordEnd: wordStart + trimmed.length });
+ const lead = word.match(/^'*/)[0].length;
+ words.push({ start, end, word: trimmed, wordStart: start + source[lead], wordEnd: start + source[lead + trimmed.length - 1] + 1 });
}
return words;
}
@@ -92,6 +102,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 +123,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 +152,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/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/styles/app-chrome.css b/src/documents/src/styles/app-chrome.css
index bb3ffb5d2..a7cc51a21 100644
--- a/src/documents/src/styles/app-chrome.css
+++ b/src/documents/src/styles/app-chrome.css
@@ -32,6 +32,14 @@ input:checked + .ed-switch { background: var(--ed-accent); border-color: var(--e
input:checked + .ed-switch::after { transform: translateX(14px); background: #fff; }
input:focus-visible + .ed-switch { outline: 2px solid var(--ed-accent); outline-offset: 2px; }
input:disabled + .ed-switch { opacity: .5; }
+/* Contrast themes drop backgrounds, which leaves the knob invisible on its track: both take system colors, the
+ track filled with the highlight color while the setting is on. */
+@media (forced-colors: active) {
+ .ed-switch { border-color: ButtonText; }
+ .ed-switch::after { forced-color-adjust: none; background: ButtonText; }
+ input:checked + .ed-switch { forced-color-adjust: none; background: Highlight; border-color: Highlight; }
+ input:checked + .ed-switch::after { background: HighlightText; }
+}
.ed-quick-customize {
display: inline-flex; align-items: center; justify-content: center; flex: none; width: 22px; height: 28px; border: 0; border-radius: 6px;
@@ -94,15 +102,26 @@ input:disabled + .ed-switch { opacity: .5; }
.ed-ribbon-reveal[hidden] { display: none; }
/* Narrower windows give the menus one button, then shorten the pills. */
-/* Below 1520px the document's name wins over the routine save label (its cloud icon still shows the state) and a wide search box. */
-@media (max-width: 1520px) { .ed-header .ed-search { width: 150px; } .ed-share .ed-pill-label { display: none; } .ed-header .ed-menubar .menu-btn { padding: 0 5px; }
- .ed-header .canvas-save-status:is(.saved, .local) .save-label { display: none; } }
-@media (max-width: 1360px) { .ed-mode-label, .ed-autosave-label, .ed-header .canvas-save-status:is(.saved, .local, .unsaved) .save-label { display: none; } .ed-header .ed-search { width: 150px; } }
+/* Below 1520px the document's name wins over a wide search box and the Share label. */
+@media (max-width: 1520px) { .ed-header .ed-search { width: 150px; } .ed-share .ed-pill-label { display: none; } .ed-header .ed-menubar .menu-btn { padding: 0 5px; } }
+/* It wins over the routine save labels too (the cloud icon still shows the state). Each edit runs through unsaved, saving
+ and saved: all three are icons alone until there is room for their labels, and then keep room for the longest of them,
+ so the centered name does not shift at every save. */
+@media (max-width: 1680px) { .ed-header .canvas-save-status:is(.saved, .local, .unsaved, .saving) .save-label { display: none; } }
+@media (min-width: 1681px) {
+ .ed-header .canvas-save-status:is(.saved, .local, .unsaved, .saving) .save-label { display: inline-grid; }
+ .ed-header .canvas-save-status:is(.saved, .local, .unsaved, .saving) .save-label::before { content: 'Unsaved changes'; height: 0; overflow: hidden; visibility: hidden; }
+}
+@media (max-width: 1360px) { .ed-mode-label, .ed-autosave-label { display: none; } .ed-header .ed-search { width: 150px; } }
@media (max-width: 1100px) { .ed-header .ed-menubar { display: none; } .ed-menus-button { display: inline-flex; } }
@media (max-width: 900px) { .ed-header .ed-search { width: 34px; padding: 0 8px; } .ed-header .ed-search:focus-within { width: 200px; } .ed-header .ed-search-input:not(:focus) { width: 0; } }
@media (max-width: 700px) {
.ed-header .ed-titlebar { grid-template-columns: auto minmax(0, 1fr) auto; gap: 4px; }
.ed-autosave, .ed-quick-customize, .ed-title-details, .ed-comments-button, .ed-share { display: none; }
+ /* Auto-hide adds the show-ribbon button to a title bar with no room to spare: the undo history list (Undo stays)
+ makes way for it, so the document's name keeps its room. */
+ .ed-workspace.ribbon-auto-hide .ed-quick .ed-undo-arrow { display: none; }
+ .ed-workspace.ribbon-auto-hide .ed-quick .ed-undo-split > button.ribbon-small { border-radius: 7px; }
.ed-pill.ed-mode { padding: 0 6px; }
.ed-header .ed-search:focus-within { position: absolute; left: 8px; right: 8px; width: auto; z-index: 5; }
}
diff --git a/src/documents/src/styles/canvas.css b/src/documents/src/styles/canvas.css
index fca248490..035fc6524 100644
--- a/src/documents/src/styles/canvas.css
+++ b/src/documents/src/styles/canvas.css
@@ -160,6 +160,18 @@
.canvas-ruler-guide { position: absolute; pointer-events: none; z-index: 4; border-color: var(--accent); }
.canvas-ruler-guide[data-axis='x'] { bottom: 0; border-left: 1px dashed; }
.canvas-ruler-guide[data-axis='y'] { right: 0; border-top: 1px dashed; }
+/* Contrast themes replace fills with the window color: the ticks and indent markers, which are fills, keep the text
+ color, and the text area keeps an edge to tell it from the margins. */
+@media (forced-colors: active) {
+ .canvas-ruler-tick { forced-color-adjust: none; background: CanvasText; opacity: 1; }
+ .canvas-ruler-tick span { color: CanvasText; }
+ .canvas-ruler-handle:is([data-kind='firstLine'], [data-kind='hanging'], [data-kind='indentRight']) { forced-color-adjust: none; color: ButtonText; }
+ .canvas-ruler-handle:is([data-kind='firstLine'], [data-kind='hanging'], [data-kind='indentRight']):hover { color: Highlight; }
+ .canvas-ruler-handle:is([data-kind='firstLine'], [data-kind='hanging'], [data-kind='indentRight']):disabled { color: GrayText; opacity: 1; }
+ .canvas-ruler-writing { box-sizing: border-box; }
+ .canvas-ruler-horizontal .canvas-ruler-writing { border-inline: 1px solid CanvasText; }
+ .canvas-ruler-vertical .canvas-ruler-writing { border-block: 1px solid CanvasText; }
+}
.canvas-ruler-value { position: absolute; top: 34px; left: 34px; z-index: 5; padding: 4px 8px; background: var(--surface); color: var(--ink); border: 1px solid var(--hairline-strong); border-radius: 4px; font-size: 12px; }
/* The style dialog's fields come in titled groups. */
.canvas-style-section { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--hairline); }
diff --git a/src/documents/src/styles/collab.css b/src/documents/src/styles/collab.css
index 041075131..a279a51f1 100644
--- a/src/documents/src/styles/collab.css
+++ b/src/documents/src/styles/collab.css
@@ -108,7 +108,8 @@
.as-box { display: flex; gap: 6px; align-items: flex-end; }
.as-input { flex: 1; resize: vertical; min-height: 60px; font-size: 13px; }
.as-disclaimer { margin: 0; font-size: 11px; color: var(--ed-ink-3); }
-.as-fab { position: absolute; right: 18px; bottom: 18px; width: 40px; height: 40px; border-radius: 50%; border: 1px solid var(--ed-line); background: var(--ed-card); box-shadow: 0 4px 14px rgba(0, 0, 0, .14); display: grid; place-items: center; cursor: pointer; z-index: 3; }
+/* It rises above the page thumbnails while they show (document-views.js sets the strip's height). */
+.as-fab { position: absolute; right: 18px; bottom: calc(18px + var(--view-thumbnails-height, 0px)); width: 40px; height: 40px; border-radius: 50%; border: 1px solid var(--ed-line); background: var(--ed-card); box-shadow: 0 4px 14px rgba(0, 0, 0, .14); display: grid; place-items: center; cursor: pointer; z-index: 3; }
.as-fab:hover, .as-fab:focus-visible { transform: translateY(-1px); box-shadow: 0 6px 18px rgba(0, 0, 0, .18); }
.as-fab[aria-expanded='true'], .as-fab[hidden], .as-title-button[hidden] { display: none; }
@@ -116,7 +117,7 @@
.as-title-button { display: none; }
.as-pane, .vh-pane { width: 100%; flex-basis: auto; }
.vh-preview { position: absolute; inset: 0; z-index: 6; border-left: 0; }
- .as-fab { right: 12px; bottom: 12px; }
+ .as-fab { right: 12px; bottom: calc(12px + var(--view-thumbnails-height, 0px)); }
.cl-presence .cl-avatar:nth-child(n+3):not(.cl-more) { display: none; }
.cl-chip { display: none; }
.cl-banner { padding: 6px 12px; font-size: 12.5px; }
diff --git a/src/documents/src/styles/components.css b/src/documents/src/styles/components.css
index ae2cac22e..e72a0e5ec 100644
--- a/src/documents/src/styles/components.css
+++ b/src/documents/src/styles/components.css
@@ -183,6 +183,12 @@ select.input, select.select {
padding-right: 34px;
cursor: pointer;
}
+/* A list of choices shown open (size or multiple) drops nothing down: no chevron, and no room kept for one. */
+select.input:where([size]:not([size="1"]), [multiple]), select.select:where([size]:not([size="1"]), [multiple]) {
+ background-image: none;
+ padding-right: 12px;
+ cursor: default;
+}
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 12px; margin-bottom: 16px; }
.grid-2 .field { margin-bottom: 0; }
diff --git a/src/documents/src/styles/document-views.css b/src/documents/src/styles/document-views.css
index 53713386b..081026fb6 100644
--- a/src/documents/src/styles/document-views.css
+++ b/src/documents/src/styles/document-views.css
@@ -46,14 +46,15 @@
/* ---- Read Mode ---- */
.ed-read-mode .ed-header, .ed-read-mode .ed-ribbon, .ed-read-mode .ed-statusbar, .ed-read-mode .canvas-message { display: none !important; }
-.ed-read-mode .canvas-document-surface { padding-top: 48px !important; padding-bottom: 32px; background: var(--read-backdrop, #f6f5f2); }
+/* The page counter's ink goes with the backdrop it sits on: each page color and Line Focus set both. */
+.ed-read-mode .canvas-document-surface { --read-backdrop: var(--ed-read-backdrop); --read-ink: var(--ed-ink-3); padding-top: 48px !important; padding-bottom: 32px; background: var(--read-backdrop); }
.ed-workspace .canvas-host.is-reading { overflow: hidden; background: transparent; outline: none; cursor: default; }
.ed-workspace .canvas-host.is-reading .canvas-pages { flex-direction: row; flex-wrap: nowrap; align-items: flex-start; gap: var(--read-gap, 32px) !important; padding: 8px var(--read-inset, 24px); width: max-content; min-width: 0; }
.ed-workspace .canvas-host.is-reading .canvas-page { outline: 0; box-shadow: none; }
-.ed-read-mode.read-line-focus .canvas-document-surface { --read-backdrop: #2b2b2b; }
+.ed-read-mode.read-line-focus .canvas-document-surface { --read-backdrop: #2b2b2b; --read-ink: #ababab; }
.canvas-host.read-sepia { --read-page: #f5ecd9; }
-.ed-read-mode:has(.read-sepia) .canvas-document-surface { --read-backdrop: #ece2cc; }
-.ed-read-mode:has(.read-inverse) .canvas-document-surface { --read-backdrop: #111; }
+.ed-read-mode:has(.read-sepia) .canvas-document-surface { --read-backdrop: #ece2cc; --read-ink: #616161; }
+.ed-read-mode:has(.read-inverse) .canvas-document-surface { --read-backdrop: #111; --read-ink: #ababab; }
.canvas-host.read-inverse .canvas-page { filter: invert(1) hue-rotate(180deg); }
.read-toolbar { position: absolute; top: 0; left: 0; right: 0; z-index: 7; display: flex; align-items: center; gap: 12px; height: 44px; padding: 0 calc(12px + var(--safe-r, 0px)) 0 calc(12px + var(--safe-l, 0px)); background: var(--ed-frame); border-bottom: 1px solid var(--ed-line); color: var(--ed-ink); }
.read-toolbar-start, .read-toolbar-end { display: flex; align-items: center; gap: 4px; flex: none; }
@@ -67,7 +68,7 @@
.read-turn:disabled { opacity: .2; }
.read-turn-back { left: 6px; }
.read-turn-next { right: 6px; }
-.read-footer { position: absolute; left: 0; right: 0; bottom: 0; z-index: 6; display: flex; justify-content: center; align-items: center; height: 30px; color: var(--ed-ink-3); font-size: 12px; }
+.read-footer { position: absolute; left: 0; right: 0; bottom: 0; z-index: 6; display: flex; justify-content: center; align-items: center; height: 30px; color: var(--read-ink, var(--ed-ink-3)); font-size: 12px; }
.ed-read-mode .read-aloud-bar { top: 56px; }
@media (max-width: 700px) {
.read-title { display: none; }
diff --git a/src/documents/src/styles/editor-chrome.css b/src/documents/src/styles/editor-chrome.css
index 5bf6a83bf..e796ac438 100644
--- a/src/documents/src/styles/editor-chrome.css
+++ b/src/documents/src/styles/editor-chrome.css
@@ -14,6 +14,8 @@
--ed-accent-wash: #e5ecf5;
--ed-workspace: #f3f3f3;
--ed-page-edge: #d4d4d4;
+ /* Behind Read Mode's pages, with no page color chosen. */
+ --ed-read-backdrop: #f6f5f2;
/* Lines in shape thumbnails, which have no fill to stand on. */
--ed-shape-line: #44546a;
}
@@ -32,6 +34,7 @@
--ed-accent-wash: #2d3d52;
--ed-workspace: #1a1a1a;
--ed-page-edge: #3a3a3a;
+ --ed-read-backdrop: #1a1a1a;
--ed-shape-line: #a7b4c6;
}
}
@@ -49,6 +52,7 @@
--ed-accent-wash: #2d3d52;
--ed-workspace: #1a1a1a;
--ed-page-edge: #3a3a3a;
+ --ed-read-backdrop: #1a1a1a;
--ed-shape-line: #a7b4c6;
}
@@ -274,12 +278,17 @@ button.ed-status-item:hover, .ed-status-view:hover, .ed-zoom-step:hover { backgr
.ed-status-view[aria-pressed="true"] { background: var(--ed-press); }
.ed-zoom { display: flex; align-items: center; gap: 2px; margin-left: 4px; }
.ed-zoom-step.icon-btn { width: 22px; height: 22px; border-radius: 4px; color: var(--ed-ink-2); }
+.ed-zoom-step[aria-disabled="true"] { opacity: .45; cursor: default; }
+.ed-zoom-step[aria-disabled="true"]:hover { background: none; }
.ed-zoom-slider { width: 110px; height: 16px; accent-color: var(--ed-ink-3); }
.ed-statusbar .canvas-zoom select { min-width: 58px; padding: 2px 16px 2px 4px; background-color: transparent; font-size: 12px; text-align: right; }
/* ---- Focus mode: the page alone ---- */
.ed-focus .ed-header, .ed-focus .ed-ribbon, .ed-focus .ed-statusbar, .ed-focus .canvas-message { display: none !important; }
.ed-focus .canvas-host { background: #1d1d1d; }
+/* The page alone: the rulers (and the tab-type button that goes with them) leave with the rest of the window. */
+.ed-focus .canvas-ruler-top, .ed-focus .canvas-ruler-side, .ed-focus .canvas-tab-selector { display: none; }
+.ed-focus .canvas-document-surface.has-rulers { padding-top: 0; }
.ed-focus-exit { position: fixed; top: 12px; right: 16px; z-index: 50; }
/* ---- Small dialogs ---- */
diff --git a/src/documents/src/styles/review.css b/src/documents/src/styles/review.css
index d42ae586f..e39942a50 100644
--- a/src/documents/src/styles/review.css
+++ b/src/documents/src/styles/review.css
@@ -1,9 +1,9 @@
/* Review: the comment markup column beside the pages. */
-.ed-workspace .canvas-host.has-comment-markup .canvas-pages { padding-right: 330px; }
+.ed-workspace .canvas-host.has-comment-markup .canvas-pages { padding-right: calc(var(--comment-card-width, 288px) + 42px); }
.ed-comments-layer { position: absolute; inset: 0 auto auto 0; width: 0; height: 0; z-index: 2; }
.ed-comments-layer[hidden] { display: none; }
.ed-comment-card {
- position: absolute; width: 288px; box-sizing: border-box; padding: 10px 12px; border-radius: 8px;
+ position: absolute; width: var(--comment-card-width, 288px); box-sizing: border-box; padding: 10px 12px; border-radius: 8px;
background: var(--ed-card); color: var(--ed-ink); border: 1px solid var(--ed-line);
box-shadow: 0 1px 2px rgba(0, 0, 0, .08), 0 2px 6px rgba(0, 0, 0, .06);
font: 12px/1.45 var(--font); cursor: default; transition: top .12s var(--ease-spring), box-shadow .12s;
@@ -36,6 +36,17 @@
.wcc-resolve { margin-top: 6px; }
.wcc-resolved { display: flex; align-items: center; justify-content: space-between; margin-top: 8px; color: var(--ed-ink-3); }
.btn.btn-sm.wcc-post, .btn.btn-sm.wcc-cancel, .btn.btn-sm.wcc-resolve { padding: 3px 10px; font-size: 12px; min-height: 26px; }
+/* Pages laid out across (Read Mode screens, Side to Side): room for the cards after each group of pages.
+ Web Layout and Draft lay the text out to the width beside the column. Phones list the cards below instead. */
+@media (min-width: 701px) {
+ .ed-workspace .canvas-host.has-comment-markup[data-card-pages="1"] .canvas-page,
+ .ed-workspace .canvas-host.has-comment-markup[data-card-pages="2"] .canvas-page:nth-child(2n),
+ .ed-workspace .canvas-host.has-comment-markup[data-card-pages="3"] .canvas-page:nth-child(3n),
+ .ed-workspace .canvas-host.has-comment-markup[data-card-pages] .canvas-page:last-child { margin-right: 312px; }
+ .ed-workspace .canvas-host.has-comment-markup.is-side-to-side .canvas-pages { padding-right: 24px; }
+ .ed-workspace .canvas-host.has-comment-markup.is-reading .canvas-pages { padding-right: var(--read-inset, 24px); }
+ .ed-workspace .canvas-host.has-comment-markup.view-web .canvas-pages, .ed-workspace .canvas-host.has-comment-markup.view-draft .canvas-pages { padding-right: calc(var(--comment-card-width, 288px) + 42px); }
+}
@media (max-width: 700px) {
.ed-workspace .canvas-host.has-comment-markup .canvas-pages { padding-right: 12px; }
.ed-comment-card { position: relative; left: auto !important; top: auto !important; width: auto; margin: 8px 12px; }
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/ui/menu.js b/src/documents/src/ui/menu.js
index 1de4b6f87..4d2264508 100644
--- a/src/documents/src/ui/menu.js
+++ b/src/documents/src/ui/menu.js
@@ -118,8 +118,14 @@ export function clickClosedMenu(anchor) {
// Whether the last press was the pointer's, so a menu it opens can hold back keyboard focus rings.
let pointerLast = false;
+// Where the mouse last was. A menu that closes or opens under a resting pointer makes the browser report the
+// pointer entering whatever is now beneath it, at the same spot: that is not the user moving onto an item.
+let pointerAt = null;
+const notePointer = (e) => { pointerAt = typeof e.clientX === 'number' ? { x: e.clientX, y: e.clientY } : null; };
+const pointerResting = (e) => !!pointerAt && typeof e.clientX === 'number' && e.clientX === pointerAt.x && e.clientY === pointerAt.y;
if (typeof document !== 'undefined') {
- document.addEventListener('pointerdown', () => { pointerLast = true; }, true);
+ document.addEventListener('pointerdown', (e) => { pointerLast = true; notePointer(e); }, true);
+ document.addEventListener('pointermove', notePointer, { capture: true, passive: true });
document.addEventListener('keydown', () => { pointerLast = false; }, true);
}
@@ -211,6 +217,9 @@ export function openMenu({ anchor, items, x = null, y = null, align = 'start', l
// it is, and so an item with a submenu can show it without being clicked.
onpointerenter: (e) => {
if (e.pointerType && e.pointerType !== 'mouse') return;
+ // Escape from a submenu drawn over its own item (a narrow window) uncovers that item under the
+ // resting pointer; opening the submenu again then would leave Escape unable to close it.
+ if (pointerResting(e)) return;
const at = depth();
if (at === -1) return;
clearTimeout(hoverTimer);
diff --git a/src/documents/src/ui/ribbon-modes.js b/src/documents/src/ui/ribbon-modes.js
index 4414fb3e3..87464f6be 100644
--- a/src/documents/src/ui/ribbon-modes.js
+++ b/src/documents/src/ui/ribbon-modes.js
@@ -61,9 +61,14 @@ export function assignKeyTips(labels, { taken = [] } = {}) {
export function createRibbonModes({ workspace, header, toolbar, ribbon, tabs, titleBar, focusEditor }) {
let mode = recallRibbonMode();
let peeking = false;
+ // A short window (a phone held sideways) folds an always-shown ribbon to its tabs, which would otherwise take most
+ // of the height from the page; choosing Always Show there keeps it shown. The remembered choice is not changed.
+ const short = typeof matchMedia === 'function' ? matchMedia('(max-height: 480px)') : null;
+ let keepShown = false;
+ const effective = () => (mode === 'always' && short?.matches && !keepShown ? 'tabs' : mode);
const tabRow = ribbon.tablist;
const collapse = el('button.ed-ribbon-toggle', { type: 'button', onclick: () => {
- if (mode === 'always') setMode('tabs'); else { setMode('always'); }
+ if (effective() === 'always') setMode('tabs'); else { setMode('always'); }
focusEditor();
} }, iconEl('chevU', 14));
collapse.addEventListener('mousedown', e => e.preventDefault());
@@ -71,9 +76,9 @@ export function createRibbonModes({ workspace, header, toolbar, ribbon, tabs, ti
collapse.tabIndex = -1;
const options = el('button.ed-ribbon-options', { type: 'button', 'aria-label': 'Ribbon display options', title: 'Ribbon display options', 'aria-haspopup': 'menu',
onclick: e => openMenu({ anchor: options, label: 'Ribbon display options', align: 'end', minWidth: 240, pointer: !!e.detail, onClose: e.detail ? focusEditor : null, items: [
- { label: 'Always Show the Ribbon', radio: true, checked: mode === 'always', onSelect: () => setMode('always') },
- { label: 'Show Tabs Only', radio: true, checked: mode === 'tabs', onSelect: () => setMode('tabs') },
- { label: 'Auto-hide the Ribbon', radio: true, checked: mode === 'auto', onSelect: () => setMode('auto') },
+ { label: 'Always Show the Ribbon', radio: true, checked: effective() === 'always', onSelect: () => setMode('always') },
+ { label: 'Show Tabs Only', radio: true, checked: effective() === 'tabs', onSelect: () => setMode('tabs') },
+ { label: 'Auto-hide the Ribbon', radio: true, checked: effective() === 'auto', onSelect: () => setMode('auto') },
{ separator: true }, { label: 'Show KeyTips', hint: IS_MAC ? 'Option' : 'Alt', onSelect: () => keyTips.show() }] }) }, commandIconEl('more', 16));
options.addEventListener('mousedown', e => e.preventDefault());
options.tabIndex = -1;
@@ -83,6 +88,7 @@ export function createRibbonModes({ workspace, header, toolbar, ribbon, tabs, ti
titleBar.querySelector('.canvas-title-actions')?.prepend(reveal);
function apply() {
+ const mode = effective();
workspace.classList.toggle('ribbon-tabs-only', mode === 'tabs');
workspace.classList.toggle('ribbon-auto-hide', mode === 'auto');
workspace.classList.toggle('ribbon-peek', peeking);
@@ -100,11 +106,12 @@ export function createRibbonModes({ workspace, header, toolbar, ribbon, tabs, ti
function setMode(next) {
if (!RIBBON_MODES.includes(next)) return;
mode = next; peeking = false;
+ keepShown = next === 'always' && !!short?.matches;
try { localStore()?.setItem(MODE_STORE, mode); } catch { /* storage unavailable */ }
apply();
}
function peek(on) {
- if (mode === 'always') on = false;
+ if (effective() === 'always') on = false;
if (peeking === on) return;
peeking = on;
apply();
@@ -114,15 +121,15 @@ export function createRibbonModes({ workspace, header, toolbar, ribbon, tabs, ti
const outside = e => { if (!toolbar.contains(e.target) && !tabRow.contains(e.target) && !reveal.contains(e.target) && !owned(e.target)) peek(false); };
// A tab click in a collapsed ribbon shows the commands over the document; clicking the open tab again hides them.
tabRow.addEventListener('click', e => {
- const tab = e.target.closest('.ribbon-tab'); if (!tab || mode === 'always') return;
+ const tab = e.target.closest('.ribbon-tab'); if (!tab || effective() === 'always') return;
const wasCurrent = tab.getAttribute('aria-selected') === 'true';
if (peeking && wasCurrent && e.detail === 1 && tab.dataset.peekedAt && Date.now() - Number(tab.dataset.peekedAt) > 300) { peek(false); return; }
tab.dataset.peekedAt = String(Date.now());
peek(true);
}, true);
- tabRow.addEventListener('dblclick', e => { if (!e.target.closest('.ribbon-tab')) return; setMode(mode === 'always' ? 'tabs' : 'always'); });
+ tabRow.addEventListener('dblclick', e => { if (!e.target.closest('.ribbon-tab')) return; setMode(effective() === 'always' ? 'tabs' : 'always'); });
// Before the tab list's own keys (capture): Down there moves into the commands, which must be showing to take focus.
- tabRow.addEventListener('keydown', e => { if (mode !== 'always' && (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') && e.target.matches('.ribbon-tab')) peek(true); }, true);
+ tabRow.addEventListener('keydown', e => { if (effective() !== 'always' && (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') && e.target.matches('.ribbon-tab')) peek(true); }, true);
// A command run from the shown commands puts them away (a menu or dialog it opens keeps them up until it closes).
toolbar.addEventListener('click', e => {
if (!peeking || e.target.closest('.ed-ribbon-toggle')) return;
@@ -154,7 +161,6 @@ export function createRibbonModes({ workspace, header, toolbar, ribbon, tabs, ti
}
function top() {
level = 'top'; typed = '';
- if (mode !== 'always' && !peeking) { /* tabs stay reachable in a collapsed ribbon */ }
const targets = [];
const file = tabRow.querySelector('.ribbon-file');
if (file) targets.push({ node: file, code: 'F', run: () => { hide(); file.click(); } });
@@ -177,7 +183,7 @@ export function createRibbonModes({ workspace, header, toolbar, ribbon, tabs, ti
}
function openTab(id) {
ribbon.select(id);
- if (mode !== 'always') peek(true);
+ if (effective() !== 'always') peek(true);
requestAnimationFrame(() => commands(id));
}
function commands(id) {
@@ -246,13 +252,16 @@ export function createRibbonModes({ workspace, header, toolbar, ribbon, tabs, ti
}, { capture: true, signal: abort.signal });
window.addEventListener('pointerdown', () => { altClean = false; }, { capture: true, signal: abort.signal });
+ // Turning the phone (or resizing the window) folds or unfolds the ribbon again.
+ const onShort = () => { peeking = false; document.removeEventListener('pointerdown', outside, true); apply(); };
+ short?.addEventListener?.('change', onShort);
apply();
return {
- get mode() { return mode; },
+ get mode() { return effective(); },
setMode,
- toggle() { setMode(mode === 'always' ? 'tabs' : 'always'); },
+ toggle() { setMode(effective() === 'always' ? 'tabs' : 'always'); },
peek, showKeyTips: () => keyTips.show(),
- beforeRun() { if (mode !== 'always') peek(true); },
- destroy() { abort.abort(); keyTips.hide(); document.removeEventListener('pointerdown', outside, true); },
+ beforeRun() { if (effective() !== 'always') peek(true); },
+ destroy() { abort.abort(); keyTips.hide(); document.removeEventListener('pointerdown', outside, true); short?.removeEventListener?.('change', onShort); },
};
}
diff --git a/src/documents/src/ui/ribbon.js b/src/documents/src/ui/ribbon.js
index 3a298cd51..4dc26d2fc 100644
--- a/src/documents/src/ui/ribbon.js
+++ b/src/documents/src/ui/ribbon.js
@@ -293,8 +293,18 @@ export function createRibbon({ tabs, fileButton, focusEditor, onSelect }) {
onSelect?.(id);
refreshTabStops();
fit();
+ revealTab(tabButtons.get(id));
if (focus) tabButtons.get(id).focus();
}
+ /** A tab row that scrolls sideways (phones) shows the chosen tab, clear of its faded edges: the document picks
+ * some tabs itself (a picture's, Outlining), which could otherwise be selected out of sight. */
+ function revealTab(button) {
+ if (tabRow.scrollWidth <= tabRow.clientWidth + 1) return;
+ const tab = button.getBoundingClientRect(), row = tabRow.getBoundingClientRect(), edge = 24;
+ if (!tab.width) return;
+ if (tab.left < row.left + edge) tabRow.scrollLeft -= row.left + edge - tab.left;
+ else if (tab.right > row.right - edge) tabRow.scrollLeft += tab.right - (row.right - edge);
+ }
tablist.addEventListener('keydown', e => {
if (isComposing(e) || !e.target.matches('.ribbon-tab')) return;
const list = visibleTabs(), at = list.indexOf(e.target);
diff --git a/src/documents/src/views/assistant-pane.js b/src/documents/src/views/assistant-pane.js
index 0bb4567e0..78226f39c 100644
--- a/src/documents/src/views/assistant-pane.js
+++ b/src/documents/src/views/assistant-pane.js
@@ -104,6 +104,13 @@ export function createAssistantPane({ editor, pkg, error, available = () => P.ca
/* ---- Messages ---- */
const message = (who, ...children) => { empty.remove(); const node = el(`div.as-message.is-${who}`, {}, ...children); log.append(node); node.scrollIntoView?.({ block: 'nearest' }); return node; };
+ /** A reply grows from "Thinking…" in place: the conversation scrolls on to show it down to its end, or from its start when it is taller. */
+ function reveal(node) {
+ if (!node.isConnected) return;
+ const view = log.getBoundingClientRect(), box = node.getBoundingClientRect();
+ if (box.bottom > view.bottom) log.scrollTop += Math.min(box.bottom - view.bottom, box.top - view.top);
+ }
+
async function submit(text) {
const request = String(text || '').trim();
if (!request || busy) return;
@@ -132,10 +139,12 @@ export function createAssistantPane({ editor, pkg, error, available = () => P.ca
if (stopped !== token) return;
history.push({ request, reply: answer.text });
waiting.replaceChildren(...answerView(answer, p));
+ reveal(waiting);
} catch (e) {
if (stopped !== token) return;
waiting.classList.add('is-error');
waiting.replaceChildren(el('p', {}, e?.message && !/AI service|could not answer/i.test(e.message) && e.message.length < 140 ? e.message : assistantError(e)));
+ reveal(waiting);
} finally {
// A reply that New chat left behind no longer holds the conversation up.
if (stopped === token) { busy = false; send.disabled = false; }
@@ -147,7 +156,8 @@ export function createAssistantPane({ editor, pkg, error, available = () => P.ca
if (answer.kind !== 'text') return [body, actions(answer, p, false)];
const replacing = p.scope === 'selection' && answer.mode !== 'append';
const original = replacing ? el('p.as-replaces', {}, `Replaces “${p.text.length > 140 ? `${p.text.slice(0, 140).trim()}…` : p.text}”`) : null;
- return [el('p.as-kind', {}, replacing ? 'Suggested replacement' : 'Suggested text'), body, original, actions(answer, p, true)];
+ // replaceChildren would write a missing line out as the word "null": only the parts there are go in.
+ return [el('p.as-kind', {}, replacing ? 'Suggested replacement' : 'Suggested text'), body, original, actions(answer, p, true)].filter(Boolean);
}
function actions(answer, p, insertable) {
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/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/src/views/design-dialogs.js b/src/documents/src/views/design-dialogs.js
index 8f7f4b639..fc7f524b8 100644
--- a/src/documents/src/views/design-dialogs.js
+++ b/src/documents/src/views/design-dialogs.js
@@ -102,7 +102,9 @@ export function openCustomWatermark({ current, apply }) {
const text = current?.kind === 'text' ? current : { text: 'DRAFT', font: null, size: 'auto', color: 'C0C0C0', semitransparent: true, layout: 'diagonal', language: 'en-US' };
const listId = fieldId('watermark-texts');
const words = el('input.input', { type: 'text', value: text.text, list: listId, maxLength: 120, 'aria-label': 'Text' });
- const fonts = el('select.input', { 'aria-label': 'Font' }, el('option', { value: '' }, 'Automatic'), ...DESIGN_FONTS.map(f => el('option', { value: f, selected: f === text.font }, f)));
+ // A watermark's own font (one Word wrote, say) is offered too, so OK keeps it.
+ const fontNames = text.font && !DESIGN_FONTS.includes(text.font) ? [text.font, ...DESIGN_FONTS] : DESIGN_FONTS;
+ const fonts = el('select.input', { 'aria-label': 'Font' }, el('option', { value: '' }, 'Automatic'), ...fontNames.map(f => el('option', { value: f, selected: f === text.font }, f)));
const size = el('select.input', { 'aria-label': 'Size' }, el('option', { value: 'auto' }, 'Auto'), ...[36, 48, 54, 60, 66, 72, 80, 90, 96, 105, 120, 144].map(n => el('option', { value: n, selected: n === text.size }, String(n))));
const color = el('input.design-color-input', { type: 'color', value: `#${text.color || 'C0C0C0'}`, 'aria-label': 'Color' });
const transparent = el('input', { type: 'checkbox', checked: text.semitransparent !== false });
diff --git a/src/documents/src/views/document-views.js b/src/documents/src/views/document-views.js
index f4665d14c..07e34a0b3 100644
--- a/src/documents/src/views/document-views.js
+++ b/src/documents/src/views/document-views.js
@@ -177,18 +177,32 @@ export function createDocumentViews({ editor, pkg, host, surface, root, error, c
sideToSide = on;
engine.pages.classList.toggle('is-side-to-side', on);
host.classList.toggle('is-side-to-side', on);
+ // Comment cards go beside each page (review.js), which keeps room for them.
+ if (on) host.dataset.cardPages = '1'; else if (mode !== 'read') delete host.dataset.cardPages;
+ showThumbnails(on && thumbnails);
if (on) {
engine.pages.classList.remove('is-multipage');
- // Whole pages, side by side: fit a page's height to the window.
- const tallest = Math.max(...engine.layout.pages.map(p => p.height));
- w.setZoom?.(Math.max(0.1, Math.min(2, Math.floor((host.clientHeight - 40) / tallest * 100) / 100)));
+ fitSideToSide();
engine.pages.classList.add('is-side-to-side');
} else if (!silent) w.setZoom?.(1);
- thumbStrip.hidden = !(on && thumbnails);
- if (on && thumbnails) paintThumbnails({ all: true });
revealCaretHorizontally();
if (!silent) { layoutChanged(); onState(); }
}
+ /** Whole pages, side by side: fit a page's height to the window. */
+ function fitSideToSide() {
+ const engine = ready(); if (!engine) return;
+ const tallest = Math.max(...engine.layout.pages.map(p => p.height));
+ w.setZoom?.(Math.max(0.1, Math.min(2, Math.floor((host.clientHeight - 40) / tallest * 100) / 100)));
+ }
+ /** The strip lies over the foot of the surface; the pages, which do not scroll up or down here, end above it. */
+ function showThumbnails(on) {
+ thumbStrip.hidden = !on;
+ if (on) paintThumbnails({ all: true });
+ host.style.marginBottom = on ? `${thumbStrip.offsetHeight}px` : '';
+ // So does the Assistant's button, which sits at the foot of the editor.
+ if (on) surface.parentElement?.style.setProperty('--view-thumbnails-height', `${thumbStrip.offsetHeight}px`);
+ else surface.parentElement?.style.removeProperty('--view-thumbnails-height');
+ }
// Shown afresh, every page is painted and every thumbnail drawn. After that a thumbnail is drawn again
// only when its page has been painted (thumbOverlay), or is a new page or a new size. Typing paints
// the pages on screen, and the rest once typing pauses: painting every page and drawing every
@@ -239,15 +253,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;
@@ -494,7 +516,7 @@ export function createDocumentViews({ editor, pkg, host, surface, root, error, c
const has = () => !!ready();
const vertical = command('Vertical', 'verticalMovement', () => setSideToSide(false), { size: 'large', attrs: { 'aria-pressed': 'true' } });
const across = command('Side to Side', 'sideToSide', () => setSideToSide(true), { size: 'large', text: 'Side to Side', attrs: { 'aria-pressed': 'false' } });
- const thumbs = command('Thumbnails', 'thumbnails', () => { thumbnails = !thumbnails; thumbStrip.hidden = !(sideToSide && thumbnails); paintThumbnails({ all: true }); onState(); }, { text: 'Thumbnails', attrs: { 'aria-pressed': 'false' } });
+ const thumbs = command('Thumbnails', 'thumbnails', () => { thumbnails = !thumbnails; showThumbnails(sideToSide && thumbnails); if (sideToSide) fitSideToSide(); onState(); }, { text: 'Thumbnails', attrs: { 'aria-pressed': 'false' } });
when(vertical, has, () => String(!sideToSide)); when(across, () => has() && mode !== 'outline', () => String(sideToSide));
when(thumbs, () => has() && sideToSide, () => String(sideToSide && thumbnails));
const newWin = command('New Window', 'newWindow', newWindow, { size: 'large', text: 'New Window' });
@@ -526,7 +548,8 @@ export function createDocumentViews({ editor, pkg, host, surface, root, error, c
const promote = command('Promote', 'promote', () => outline.promote(), { shortcut: `${outline.shortcutHint}+Left` });
const levelMenu = menuButton('Outline Level', 'outlineView', () => outline.levelNames.map((label, i) => ({ label, radio: true, checked: level() === i + 1, onSelect: () => outline.setLevel(i + 1) })), { size: 'small', text: 'Outline Level' });
const demote = command('Demote', 'demote', () => outline.demote(), { shortcut: `${outline.shortcutHint}+Right` });
- const toBody = command('Demote to Body Text', 'demoteBody', () => outline.demoteToBody(), { shortcut: `${MOD_KEY}+Shift+N` });
+ // Ctrl+Shift+N on a Mac too, where Cmd+Shift+N opens a private window and never reaches the page.
+ const toBody = command('Demote to Body Text', 'demoteBody', () => outline.demoteToBody(), { shortcut: 'Ctrl+Shift+N' });
const up = command('Move Up', 'moveUp', () => outline.moveUp(), { shortcut: `${outline.shortcutHint}+Up` });
const down = command('Move Down', 'moveDown', () => outline.moveDown(), { shortcut: `${outline.shortcutHint}+Down` });
const expand = command('Expand', 'expand', () => outline.expand(), { shortcut: `${outline.shortcutHint}+Plus` });
diff --git a/src/documents/src/views/draw-commit.js b/src/documents/src/views/draw-commit.js
index d6af512cf..2a63266e5 100644
--- a/src/documents/src/views/draw-commit.js
+++ b/src/documents/src/views/draw-commit.js
@@ -10,6 +10,7 @@
*/
import { readDrawingFragment, nextDrawingOrder, nextDrawingId } from '../engine/inserted-drawings.js';
import { decodeImages, inlineImagePaintFrame, isPositionedImage } from '../engine/images.js';
+import { paragraphText } from '../engine/model.js';
import { inkDrawingXml, readInkDrawingCached, EMU_PER_PX } from '../engine/docx-ink.js';
import { strokeBounds, unionBounds } from '../engine/ink-geometry.js';
@@ -54,18 +55,35 @@ export function imageOffset(model, image) {
/**
* Where ink at `bounds` on `page` is anchored: the last body paragraph that starts on that page at or
* above the ink (else the page's first), with a paragraph-relative vertical offset so the ink moves
- * with its text. Returns { paragraphId, anchor } or null when no paragraph starts on the page.
+ * with its text. A page where no body paragraph starts (all table rows, or a paragraph running on from
+ * the page before) anchors the ink in a table cell paragraph that starts there, or else at the start of
+ * the page's first line, positioned from that line, so the ink stays on the page it was drawn on.
+ * Returns { paragraphId, lineOffset?, anchor } (lineOffset: where in the paragraph its character goes),
+ * or null when nothing on the page can hold it.
*/
export function inkAnchor(engine, page, bounds, { behind = false, order = 1 } = {}) {
const lines = engine.layout.lines || [], first = new Map();
for (const line of lines) if (line.paragraphId != null && !first.has(line.paragraphId)) first.set(line.paragraphId, line);
- const starts = [...first.values()].filter(line => line.page === page && !line.cellId && !line.pageStory && !line.header);
- if (!starts.length) return null;
- const top = line => line.y - (line.anchorBefore || 0);
- const above = starts.filter(line => top(line) <= bounds.top + 0.5);
- const line = above.length ? above.reduce((a, b) => (top(b) >= top(a) ? b : a)) : starts.reduce((a, b) => (top(b) < top(a) ? b : a));
const clampOffset = v => Math.max(-MAX_OFFSET, Math.min(MAX_OFFSET, Math.round(v)));
- return { paragraphId: line.paragraphId, anchor: { horizontal: 'page', vertical: 'paragraph', x: clampOffset(bounds.left * EMU_PER_PX), y: clampOffset((bounds.top - top(line)) * EMU_PER_PX), wrap: 'none', behind, order } };
+ const top = line => line.y - (line.anchorBefore || 0);
+ const nearest = starts => { const above = starts.filter(line => top(line) <= bounds.top + 0.5); return above.length ? above.reduce((a, b) => (top(b) >= top(a) ? b : a)) : starts.reduce((a, b) => (top(b) < top(a) ? b : a)); };
+ const make = (line, x, vertical, y, extra = {}) => ({ paragraphId: line.paragraphId, ...extra, anchor: { horizontal: 'page', vertical, x: clampOffset(x * EMU_PER_PX), y: clampOffset(y * EMU_PER_PX), wrap: 'none', behind, order } });
+ const onPage = [...first.values()].filter(line => line.page === page && !line.pageStory && !line.header);
+ const starts = onPage.filter(line => !line.cellId);
+ if (starts.length) { const line = nearest(starts); return make(line, bounds.left, 'paragraph', bounds.top - top(line)); }
+ // In a cell, page coordinates are measured from the cell (positioned-images.js).
+ const cellStarts = onPage.filter(line => line.cellId && (engine.layout.cells || []).some(c => c.id === line.cellId && c.page === page));
+ if (cellStarts.length) {
+ const line = nearest(cellStarts), cell = engine.layout.cells.find(c => c.id === line.cellId && c.page === page);
+ return make(line, bounds.left - cell.x, 'paragraph', bounds.top - top(line));
+ }
+ // A paragraph that began on an earlier page: its first line here holds the ink, which keeps to that line.
+ const running = lines.filter(line => line.page === page && !line.cellId && !line.pageStory && !line.header && line.paragraphId != null && Number.isFinite(line.start)).sort((a, b) => a.y - b.y)[0];
+ if (running) {
+ const p = engine.model.paragraphs.findIndex(x => x.id === running.paragraphId), start = p < 0 ? -1 : engine.model.paragraphOffset(p);
+ if (start >= 0 && running.start > start) return make(running, bounds.left, 'line', bounds.top - running.y, { lineOffset: running.start - start });
+ }
+ return null;
}
/** A frame around strokes (page pixels), with a pixel of room, and the strokes moved into it. */
@@ -96,12 +114,12 @@ export function createInkCommitter({ editor, pkg, onApplied = () => {}, onError
// Drawing ids are written into the saved frame, so each new drawing takes the next unused one.
const id = op.replace ? op.replace.id : nextDrawingId(model) + index;
const assetId = `${INK_ASSET}${Date.now().toString(36)}-${++assetCounter}`;
- let xml, paragraphId = null;
+ let xml, paragraphId = null, lineOffset;
if (op.addXml) {
// Another drawing (a recognised shape) placed like ink: anchored to the paragraph under it.
const placed = inkAnchor(engine, op.page, op.bounds, { behind: false, order: nextDrawingOrder(model) + index });
if (!placed) throw new Error('Shapes need a paragraph that starts on this page. Add a paragraph here first.');
- paragraphId = placed.paragraphId;
+ paragraphId = placed.paragraphId; lineOffset = placed.lineOffset;
xml = op.addXml(id, placed.anchor);
} else if (op.add && op.canvas) {
xml = inkDrawingXml({ id, strokes: [], width: op.width, height: op.height, canvas: true });
@@ -109,7 +127,7 @@ export function createInkCommitter({ editor, pkg, onApplied = () => {}, onError
const frame = frameStrokes(op.add);
const placed = inkAnchor(engine, op.page, frame, { behind: !!op.behind, order: nextDrawingOrder(model) + index });
if (!placed) throw new Error('Ink needs a paragraph that starts on this page. Add a paragraph here first.');
- paragraphId = placed.paragraphId;
+ paragraphId = placed.paragraphId; lineOffset = placed.lineOffset;
xml = inkDrawingXml({ id, strokes: frame.strokes, width: frame.width, height: frame.height, anchor: placed.anchor });
} else if (op.replace) {
const current = inkFrames(engine).find(f => f.image === op.replace);
@@ -133,7 +151,7 @@ export function createInkCommitter({ editor, pkg, onApplied = () => {}, onError
} else return { op };
const fragment = await readDrawingFragment(xml, { sourceParts: pkg()?.parts || {}, assetId });
const bitmaps = await decodeImages(fragment.assets, model.styles.fontAlternatives, () => {});
- return { op, fragment, bitmaps, paragraphId };
+ return { op, fragment, bitmaps, paragraphId, lineOffset };
}
/** The text offset of a paragraph's start, by id, in the current model. */
@@ -169,6 +187,8 @@ export function createInkCommitter({ editor, pkg, onApplied = () => {}, onError
for (const b of built.filter(b => b.op.add || b.op.addXml)) {
let at = b.paragraphId != null ? paragraphStart(model, b.paragraphId) : caret;
if (at < 0) throw new Error('The paragraph under the ink is gone. Try again.');
+ // Within a paragraph running on from an earlier page: at the start of its line on the ink's page.
+ if (b.lineOffset) { const p = model.paragraphs.find(x => x.id === b.paragraphId); at += Math.min(b.lineOffset, paragraphText(p).length); }
if (b.op.canvas) {
// A drawing canvas takes a paragraph of its own.
const text = model.text, lineStart = text.lastIndexOf('\n', at - 1) + 1, lineEnd = text.indexOf('\n', at) < 0 ? text.length : text.indexOf('\n', at);
diff --git a/src/documents/src/views/draw-surface.js b/src/documents/src/views/draw-surface.js
index 74d4d180e..43d8dbb99 100644
--- a/src/documents/src/views/draw-surface.js
+++ b/src/documents/src/views/draw-surface.js
@@ -7,6 +7,7 @@
* Overlay layers per page: strokes standing in for hidden drawings (being erased, moved or replayed),
* strokes waiting to be committed, the stroke being drawn, the lasso loop and the selection frame.
*/
+import { pageBackingSize } from '../engine/canvas-backing.js';
import { el, isComposing } from '../ui/dom.js';
import { filterPoints, strokeOutline, rulerEdge, snapToEdge, unionBounds, strokeBounds, widthAt } from '../engine/ink-geometry.js';
@@ -55,14 +56,17 @@ export function createDrawSurface({ editor, handlers }) {
if (!node) { node = el('canvas.draw-overlay', { 'aria-hidden': 'true' }); overlays.set(page, node); layer.append(node); }
const ratio = window.devicePixelRatio || 1, width = canvas.offsetWidth, height = canvas.offsetHeight;
Object.assign(node.style, { left: `${canvas.offsetLeft}px`, top: `${canvas.offsetTop}px`, width: `${width}px`, height: `${height}px` });
- if (node.width !== Math.round(width * ratio) || node.height !== Math.round(height * ratio)) { node.width = Math.round(width * ratio); node.height = Math.round(height * ratio); }
+ // Within the browser's canvas size limit, as the page canvas is (canvas-backing.js); render() scales by the result.
+ const backing = pageBackingSize(width, height, ratio);
+ if (node.width !== backing.width || node.height !== backing.height) { node.width = backing.width; node.height = backing.height; }
+ node.cssWidth = width; node.cssHeight = height;
return node;
}
/** Repaint every overlay that has anything to show; clear the rest. */
function render() {
frame = 0;
const ed = engine(); if (!ed) return;
- const zoom = ed.zoom || 1, ratio = window.devicePixelRatio || 1;
+ const zoom = ed.zoom || 1;
const pages = new Set([...pending.keys(), ...scenes.keys(), ...overlays.keys()]);
if (current?.page != null) pages.add(current.page);
if (lasso) pages.add(lasso.page); if (selection) pages.add(selection.page); if (cursor) pages.add(cursor.page);
@@ -70,7 +74,8 @@ export function createDrawSurface({ editor, handlers }) {
const node = overlay(page); if (!node) continue;
const ctx = node.getContext('2d');
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, node.width, node.height);
- ctx.setTransform(zoom * ratio, 0, 0, zoom * ratio, 0, 0);
+ // Device pixels per CSS pixel of this overlay: the display's ratio, or fewer on a page too large for it.
+ ctx.setTransform(zoom * node.width / (node.cssWidth || 1), 0, 0, zoom * node.height / (node.cssHeight || 1), 0, 0);
// Highlighter ink multiplies with the page beneath; it is drawn on its own blended layer.
node.classList.toggle('has-highlighter', [...(scenes.get(page) || []), ...(pending.get(page) || []), ...(current?.page === page && current.stroke ? [current.stroke] : [])].some(s => s.tool === 'highlighter'));
for (const stroke of scenes.get(page) || []) paintStroke(ctx, stroke, { alpha: stroke.alpha ?? 1 });
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/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 b52c540fb..944707a19 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';
@@ -90,7 +91,7 @@ import { listVersions } from '../lib/versions.js';
import { publishPage, findSite, unpublishPage } from '../lib/publish.js';
import { listCustomTemplates } from '../lib/customtemplates.js';
import { Doc, versioning, publishing } from '../lib/doc.js';
-import { currentTheme, isDark, darkPage, setDarkPage } from '../ui/theme.js';
+import { currentTheme, isDark, darkPage, setDarkPage, onThemeChange } from '../ui/theme.js';
import { UNITS, recallUnit, rememberUnit } from '../lib/units.js';
import { setAutocorrectEnabled, setCapitalizeSentencesEnabled } from '../lib/autocorrect.js';
import { setAutolinkEnabled } from '../lib/linkify.js';
@@ -171,8 +172,17 @@ 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;
+ // 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.
@@ -181,7 +191,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 +202,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 +222,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;
@@ -272,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.
@@ -490,9 +501,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 } = {}) {
@@ -513,7 +527,7 @@ export function editorView({ doc, goHome, newDoc, importFile, openPath, newFromT
toggleOutline(true, { search: true });
if (selected && !/[\n\f\v]/.test(selected) && selected.length <= 255) outlinePanel.setSearch(selected);
};
- const outline = tool('Document outline', 'outline', () => toggleOutline(), { 'aria-expanded': 'false', 'aria-controls': outlinePanel.node.id, shortcut: `${MOD_KEY}+Alt+O` });
+ const outline = tool('Document outline', 'outline', () => toggleOutline(), { 'aria-expanded': 'false', 'aria-controls': outlinePanel.node.id, shortcut: MOD_KEY === 'Cmd' ? 'Cmd+Option+O' : 'Ctrl+Alt+O' });
const outlineLevel = el('select', { disabled: true, 'aria-label': 'Outline level', title: 'Outline level does not change text formatting', onchange: () => engine?.setOutlineLevel(outlineLevel.value === 'inherit' ? null : Number(outlineLevel.value)) },
el('option', { value: 'mixed', disabled: true }, 'Mixed levels'), el('option', { value: 'inherit' }, 'Use style level'),
...Array.from({ length: 9 }, (_, i) => el('option', { value: i }, `Level ${i + 1}`)), el('option', { value: 9 }, 'Body text'));
@@ -721,12 +735,20 @@ export function editorView({ doc, goHome, newDoc, importFile, openPath, newFromT
// The slider's value is a percentage, and says so: a screen reader read a bare "100".
const zoomSlider = el('input.ed-zoom-slider', { type: 'range', min: 10, max: 200, step: 1, value: 100, 'aria-label': 'Zoom slider', 'aria-valuetext': '100%', oninput: () => zoomControl.set(Number(zoomSlider.value) / 100),
onpointerdown: () => { zoomDragged = true; }, onchange: () => { if (zoomDragged) { zoomDragged = false; engine?.focus(); } } });
- const zoomStep = (label, icon, delta) => el('button.icon-btn.ed-zoom-step', { type: 'button', 'aria-label': label, title: label, onclick: () => {
+ // At the largest or smallest zoom the step has nothing to do: it says so (aria-disabled) and keeps the keyboard,
+ // which a disabled button would drop while the zoom is being stepped from it.
+ const zoomStep = (label, icon, delta) => el('button.icon-btn.ed-zoom-step', { type: 'button', 'aria-label': label, title: label, onclick: e => {
+ if (e.currentTarget.getAttribute('aria-disabled') === 'true') return;
const now = Math.round((engine?.zoom ?? 1) * 100), next = delta > 0 ? Math.floor(now / 10) * 10 + 10 : Math.ceil(now / 10) * 10 - 10;
zoomControl.set(Math.max(10, Math.min(200, next)) / 100);
} }, commandIconEl(icon, 12));
const zoomOut = zoomStep('Zoom Out', 'zoomOut', -1), zoomIn = zoomStep('Zoom In', 'zoomIn', 1);
- function syncZoom() { if (engine) { zoomSlider.value = String(Math.round(engine.zoom * 100)); zoomSlider.setAttribute('aria-valuetext', `${zoomSlider.value}%`); } }
+ function syncZoom() {
+ if (!engine) return;
+ const percent = Math.round(engine.zoom * 100);
+ zoomSlider.value = String(percent); zoomSlider.setAttribute('aria-valuetext', `${percent}%`);
+ zoomOut.setAttribute('aria-disabled', String(percent <= 10)); zoomIn.setAttribute('aria-disabled', String(percent >= 200));
+ }
function fitWidth() {
if (!engine || zoom.value !== 'fit' || !host.clientWidth) return;
const style = getComputedStyle(engine.pages);
@@ -791,8 +813,11 @@ export function editorView({ doc, goHome, newDoc, importFile, openPath, newFromT
const tablesBaseExtendMenu = editorCommands.extendMenu, tablesBaseContextMenuItems = editorCommands.contextMenuItems;
editorCommands.extendMenu = (id, kit) => [...(tablesBaseExtendMenu?.(id, kit) || []), ...tables.extendMenu(id, kit)];
editorCommands.contextMenuItems = (context, kit) => [...(tablesBaseContextMenuItems?.(context, kit) || []), ...tables.contextMenuItems(context, kit)];
+ // Filled in once the review tools (which own document protection) exist.
+ const protectionHooks = {};
+ const protectedDocument = () => !!protectionHooks.protectedDocument?.();
const references = createReferences({ editor: () => engine, pkg: () => pkg, error, onChange: () => update(),
- base: { canEditHeader: editorCommands.canEditHeader, editHeader: editorCommands.editHeader, canUpdateFields: editorCommands.canUpdateFields, updateFields: editorCommands.updateFields,
+ base: { protectedDocument, canEditHeader: editorCommands.canEditHeader, editHeader: editorCommands.editHeader, canUpdateFields: editorCommands.canUpdateFields, updateFields: editorCommands.updateFields,
openBookmarks: () => bookmarks.click(), showFieldCodes: () => { if (engine && !editorCommands.marksVisible?.()) editorCommands.toggleMarks?.(); } } });
const baseExtendMenu = editorCommands.extendMenu, baseContextMenuItems = editorCommands.contextMenuItems;
Object.assign(editorCommands, references.commands);
@@ -815,7 +840,7 @@ export function editorView({ doc, goHome, newDoc, importFile, openPath, newFromT
const review = createReview({ editor: () => engine, pkg: () => pkg, host: () => (engine && !failed ? host : null), error, author: () => session.username, onChange: () => update(), onShift: () => { fitWidth(); rulers?.update(); }, hooks: reviewHooks });
// Insert tab: galleries, pictures, drawings, fields, equations and symbols (insert-tab.js).
const insert = createInsert({ editor: () => engine, pkg: () => pkg, error, docName: () => title.value, user: () => (!local && session.signedIn ? session.username : ''), onChange: () => update(),
- base: { editHeader: editorCommands.editHeader, insertBlankPage: editorCommands.insertBlankPage, updateFields: editorCommands.updateFields, editDrawingText: options => editorCommands.drawingTextItems()?.find(item => !item.disabled)?.onSelect(options),
+ base: { protectedDocument, editHeader: editorCommands.editHeader, insertBlankPage: editorCommands.insertBlankPage, updateFields: editorCommands.updateFields, editDrawingText: options => editorCommands.drawingTextItems()?.find(item => !item.disabled)?.onSelect(options),
link: () => editHyperlink(), bookmark: () => bookmarks.click(), comment: () => review.commands?.newComment?.() } });
const baseMenu = editorCommands.extendMenu, baseContextItems = editorCommands.contextMenuItems;
Object.assign(editorCommands, insert.commands);
@@ -958,7 +983,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) },
] },
@@ -997,9 +1022,10 @@ export function editorView({ doc, goHome, newDoc, importFile, openPath, newFromT
// Review tools: Editor pane, Thesaurus, Accessibility, Language, Compare, Restrict Editing (review-tools.js).
const reviewTools = createReviewTools({ editor: () => engine, pkg: () => pkg, error, author: () => session.username, review, proofing, commands: editorCommands, documentName: () => title.value, openDocument: file => importFile?.(file),
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 });
+ // Under protection (other than Lock Tracking) the Mailings commands, which rewrite the document and its settings, are unavailable,
+ // and so are new headers and footers and note and page-number settings, which no protected edit check sees.
+ mailingHooks.protectedDocument = protectionHooks.protectedDocument = () => reviewTools.protectionEnforced && !reviewTools.trackingLocked;
+ 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()];
@@ -1311,7 +1337,21 @@ 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.
+ // Windows and Linux browsers always paste for the chord, though the paste event can come after a timer would
+ // fire, so nothing else pastes there. On a Mac the chord may paste nothing: the clipboard is read instead, and
+ // a browser paste that still follows is dropped, so the text never comes in twice.
+ if (e.target === engine.input && !pkg.readOnly) {
+ const editor = engine;
+ editor.plainPaste = true;
+ if (IS_MAC) setTimeout(() => { if (editor.plainPaste) { editor.plainPaste = false; editor.plainPasteReadAt = performance.now(); if (engine === editor) pastePlainText(); } });
+ else setTimeout(() => { editor.plainPaste = false; }, 1000);
+ return;
+ }
+ e.preventDefault(); pastePlainText(); return;
+ }
const action = formattingShortcut(e);
if (action) { e.preventDefault(); action(); }
});
@@ -1487,16 +1527,49 @@ export function editorView({ doc, goHome, newDoc, importFile, openPath, newFromT
updateWordCount();
statusBar?.refresh();
}
+ // Scrolled away from the caret, the status bar names the page in view, as desktop word processors do; while the
+ // caret's page shows, and at the next caret move or edit (update), it names the caret's. Once a frame, reading only
+ // the pages' offsets, which scrolling leaves laid out.
+ let pageInViewFrame = 0;
+ host.addEventListener('scroll', () => {
+ if (pageInViewFrame) return;
+ pageInViewFrame = requestAnimationFrame(() => {
+ pageInViewFrame = 0;
+ if (!engine || failed || destroyed || host.hidden) return;
+ const index = pageInView();
+ if (index !== null) stats.textContent = `Page ${index + 1} of ${engine.layout.pageCount}`;
+ });
+ }, { passive: true });
+ function pageInView() {
+ const list = engine.canvases, count = engine.layout.pageCount;
+ if (!list.length) return null;
+ const top = host.scrollTop, bottom = top + host.clientHeight, left = host.scrollLeft, right = left + host.clientWidth;
+ const shows = canvas => canvas && canvas.offsetTop < bottom && canvas.offsetTop + canvas.offsetHeight > top && canvas.offsetLeft < right && canvas.offsetLeft + canvas.offsetWidth > left;
+ const caret = Math.min(count - 1, engine.caretPageIndex ?? 0);
+ if (shows(list[caret])) return caret;
+ // The last page starting above the middle of the view (pages run down, or across in rows)…
+ const x = (left + right) / 2, y = (top + bottom) / 2;
+ let lo = 0, hi = list.length - 1, at = 0;
+ while (lo <= hi) { const mid = (lo + hi) >> 1; if (list[mid].offsetTop <= y) { at = mid; lo = mid + 1; } else hi = mid - 1; }
+ // …and of its row, the one nearest the middle across.
+ let best = at, distance = Infinity;
+ for (let i = at; i >= 0 && list[i].offsetTop === list[at].offsetTop; i--) {
+ const c = list[i], d = x < c.offsetLeft ? c.offsetLeft - x : Math.max(0, x - c.offsetLeft - c.offsetWidth);
+ if (d < distance) { distance = d; best = i; }
+ }
+ return Math.min(best, count - 1);
+ }
// The counts last shown, with the text and selection they were taken from: every caret move and each keystroke
// (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'}`;
@@ -1611,8 +1684,12 @@ export function editorView({ doc, goHome, newDoc, importFile, openPath, newFromT
stepDesignVersion(direction);
return true;
};
+ // Dark paper on screen with the dark appearance's Dark page preference, from the first paint.
+ created.darkPage = isDark() && darkPage();
return created;
}
+ // Switching the appearance or the preference repaints the open pages at once.
+ const offTheme = onThemeChange(() => engine?.setDarkPage?.(isDark() && darkPage()));
/*
* Design-wide changes (themes, style sets, watermarks, page fills) rewrite parts of the saved package
* and reopen the result in place. Each keeps the bytes before and after it, so Undo past the editor's
@@ -1730,7 +1807,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;
@@ -1845,6 +1922,7 @@ export function editorView({ doc, goHome, newDoc, importFile, openPath, newFromT
finally { clearTimeout(timer); }
},
destroy() {
+ offTheme();
drawingTextModal?.close();
printAbort.abort();
destroyed = true; linkCard.destroy(); tables.destroy(); paragraphBorderModal?.close(); paragraphShadingModal?.close(); bookmarkModal?.close(); hyperlinkModal?.close(); pageSetupModal?.close(); paragraphModal?.close(); styleModal?.close(); tableModal?.close(); imageModal?.close(); fontPopover?.close(); colorPalette?.close(); highlightPalette?.close(); baselineModal?.close(); characterSpacingModal?.close(); textWidthModal?.close(); kerningModal?.close(); underlineModal?.close();
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 742a33159..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)) {
@@ -381,6 +391,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 +420,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 +461,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/src/views/go-to.js b/src/documents/src/views/go-to.js
index 07a6ce9e3..ac53e6408 100644
--- a/src/documents/src/views/go-to.js
+++ b/src/documents/src/views/go-to.js
@@ -1,6 +1,7 @@
import { el } from '../ui/dom.js';
import { openModal } from '../ui/modal.js';
import { paragraphText } from '../engine/model.js';
+import { pageNumberSequence, formatPageNumber } from '../engine/page-numbering.js';
const KINDS = [['page', 'Page'], ['line', 'Line'], ['bookmark', 'Bookmark'], ['heading', 'Heading'], ['table', 'Table'], ['graphic', 'Graphic']];
@@ -11,8 +12,19 @@ export function goToTargets(engine, kind) {
// A page without body text (a full-page picture) goes to where the text before it ends.
const first = new Map();
for (const line of lines) if (!first.has(line.page)) first.set(line.page, line.start);
+ // Each page with the number printed on it, as its section numbers pages (§17.6.12 pgNumType): sections can
+ // restart numbering, count from 0 or show letters and Roman numerals.
+ const sections = engine.layout.pageSections || [], definition = engine.pageStories;
+ let numbers = [];
+ try { numbers = pageNumberSequence(sections, definition, engine.layout.numberingApproximations); } catch { numbers = []; }
let last = 0;
- return Array.from({ length: engine.layout.pageCount }, (_, i) => { if (first.has(i)) last = first.get(i); return { position: last, label: `Page ${i + 1}` }; });
+ return Array.from({ length: engine.layout.pageCount }, (_, i) => {
+ if (first.has(i)) last = first.get(i);
+ const number = numbers[i] ?? null, section = sections[i] ?? null;
+ let text = number === null ? null : String(number);
+ try { if (number !== null) text = formatPageNumber(number, definition?.sections?.[section]?.pageNumbering?.format || 'decimal').trim() || null; } catch { /* the plain number */ }
+ return { position: last, label: `Page ${i + 1}`, number, text, section };
+ });
}
if (kind === 'line') return lines.map((line, i) => ({ position: line.start, label: `Line ${i + 1}` }));
// Hidden bookmarks (_Toc…, _Ref…, _GoBack) are not offered, as in the Bookmarks dialog.
@@ -31,6 +43,19 @@ export function goToTargets(engine, kind) {
return out;
}
+/**
+ * The page a typed page number names: the first page numbered so ("8", or as it is shown: "iv", "L", "aa"), or
+ * with p and s the page numbered so in that section ("p2s3", sections counted from 1). A plain number no page
+ * carries goes to that page counting from the first. undefined when the entry is not a page number at all.
+ */
+export function printedPage(pages, value) {
+ const inSection = /^p\s*(\d+)\s*s\s*(\d+)$/i.exec(value);
+ if (inSection) return pages.find(p => p.number === Number(inSection[1]) && p.section === Number(inSection[2]) - 1) || null;
+ if (/^\d+$/.test(value)) return pages.find(p => p.number === Number(value)) || pages[Number(value) - 1] || null;
+ if (!/^[a-z]+$/i.test(value)) return undefined;
+ return pages.find(p => p.text === value) || pages.find(p => p.text?.toLowerCase() === value.toLowerCase()) || null;
+}
+
/**
* Go To: choose a kind of target, then a number (or +n / −n from the current one), or step
* with Previous and Next. Bookmarks are chosen by name.
@@ -47,7 +72,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';
@@ -82,6 +108,12 @@ export function openGoTo(engine, { onClose } = {}) {
}
const value = number.value.trim();
if (!value) { step(1); return; }
+ if (kind.value === 'page' && !/^[+−-]/.test(value)) {
+ const pages = goToTargets(engine, 'page'), target = printedPage(pages, value);
+ if (target === undefined) { fail('Enter a page number, + or − followed by a number, or p and s numbers such as p2s3 for page 2 of section 3.'); return; }
+ if (!target) { fail(/^\d+$/.test(value) ? `This document has ${pages.length} page${pages.length === 1 ? '' : 's'}.` : `This document has no page ${value}.`); return; }
+ land(target.position); modal.close(); return;
+ }
const match = /^([+-]?)(\d+)$/.exec(value.replace('−', '-'));
if (!match) { fail('Enter a number, or + or − followed by a number.'); return; }
const targets = goToTargets(engine, kind.value), n = Number(match[2]);
diff --git a/src/documents/src/views/home.js b/src/documents/src/views/home.js
index f38ea9906..54d5e8050 100644
--- a/src/documents/src/views/home.js
+++ b/src/documents/src/views/home.js
@@ -121,6 +121,9 @@ export function homeView({ openPath, newDoc, newFromTemplate, importFile }) {
});
renderAccountButton(avatar);
+ // Sorting and searching work on the listed documents: signed out, there is no list for them to work on.
+ const listTools = el('div.home-list-tools', {}, sortBtn, search);
+
const root = el('div.home.view', {},
fileInput,
el('header.home-head', {},
@@ -135,7 +138,7 @@ export function homeView({ openPath, newDoc, newFromTemplate, importFile }) {
P.canPickFiles() && el('button.btn.btn-outline.btn-lg', { type: 'button', html: icon('folderOpen', 18), onclick: openFromPuter }, el('span', {}, 'Open from Puter'))),
el('div.home-list-head', {},
el('h2.home-h2', {}, 'Your documents'),
- el('div.home-list-tools', {}, sortBtn, search)),
+ listTools),
list, listStatus),
// Inside the Puter desktop a new document is saved where the user chooses; the list shows those with the app folder's own.
el('footer.home-foot', {}, ...(P.asksWhereToSave()
@@ -426,6 +429,7 @@ export function homeView({ openPath, newDoc, newFromTemplate, importFile }) {
const focusedControl = pending?.control ?? ['file-main', 'file-star-btn', 'file-menu-btn'].find((name) => active?.classList.contains(name));
const focusedIndex = pending?.index ?? null;
clear(list);
+ listTools.hidden = session.signedOut;
// Loading, errors, and empty states are messages, not document list items.
list.setAttribute('role', 'region');
if (session.signedOut) {
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/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/src/views/insert-dialogs.js b/src/documents/src/views/insert-dialogs.js
index ed75d0b7a..766a02f66 100644
--- a/src/documents/src/views/insert-dialogs.js
+++ b/src/documents/src/views/insert-dialogs.js
@@ -11,7 +11,7 @@ import { formatDatePicture, DATE_PICTURES, FIELD_CATALOG, DOCUMENT_PROPERTY_NAME
import { linearToOmml, ommlToLinear, EQUATION_GALLERY } from '../engine/math-linear.js';
import { ommlToMathml } from '../engine/omml-mathml.js';
import { ICONS, ICON_CATEGORIES, iconSvg, searchIcons } from '../lib/icon-library.js';
-import { DIAGRAM_LAYOUTS, DIAGRAM_COLORS, DEFAULT_OUTLINE, parseOutline, diagramShapes } from '../engine/diagram-builder.js';
+import { DIAGRAM_LAYOUTS, DIAGRAM_COLORS, DEFAULT_OUTLINE, parseOutline, diagramShapes, hiddenDiagramItems } from '../engine/diagram-builder.js';
const field = (label, control, hint) => el('label.field', {}, el('span.field-label', {}, label), control, hint ? el('span.field-hint', {}, hint) : null);
const select = (label, options, value) => {
@@ -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() });
} });
@@ -346,18 +346,30 @@ export function openDiagramDialog({ initial = null, editing = false, onInsert, o
const edge = { Home: options[0], End: options.at(-1) }[e.key];
if (at < 0 || !step && !edge) return; e.preventDefault(); const next = edge || options[(at + step + options.length) % options.length]; next.focus(); next.click();
});
- const render = () => preview.replaceChildren(diagramSketch(layout, parseOutline(outline.value), colors.value));
+ // Items the chosen layout leaves out of its picture (deeper levels, sub-items, items past its room) are named, not dropped silently.
+ const omitted = el('p.field-hint.insert-diagram-omitted', { 'aria-live': 'polite', hidden: true });
+ const render = () => {
+ const items = parseOutline(outline.value), hidden = hiddenDiagramItems(layout, items), name = DIAGRAM_LAYOUTS.find(entry => entry.id === layout)?.name || 'This layout';
+ preview.replaceChildren(diagramSketch(layout, items, colors.value));
+ const list = hidden.length > 6 ? `${hidden.slice(0, 5).map(t => `“${t}”`).join(', ')} and ${hidden.length - 5} more` : hidden.map(t => `“${t}”`).join(', ');
+ omitted.textContent = hidden.length ? `${name} does not show ${hidden.length === 1 ? 'this item' : `these ${hidden.length} items`}: ${list}. ${hidden.length === 1 ? 'It stays' : 'They stay'} in the diagram's text; choose another layout to show ${hidden.length === 1 ? 'it' : 'them'}.` : '';
+ omitted.hidden = !hidden.length;
+ };
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, omitted))],
onSubmit: () => {
const items = parseOutline(outline.value);
if (!items.length) throw new Error('Type at least one item.');
diff --git a/src/documents/src/views/insert-tab.js b/src/documents/src/views/insert-tab.js
index b55ef3886..4e9fca4ee 100644
--- a/src/documents/src/views/insert-tab.js
+++ b/src/documents/src/views/insert-tab.js
@@ -23,7 +23,8 @@ 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 { 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';
@@ -41,8 +42,12 @@ export async function rasterize(blob, max = 1600, { pixels = Infinity, type = 'i
const img = new Image(); img.src = url;
try { await img.decode(); } catch { throw new Error('The picture could not be read. Choose a PNG, JPEG, GIF, WebP, BMP, TIFF or SVG image.'); }
let width = img.naturalWidth || 300, height = img.naturalHeight || 150;
- const scale = Math.min(1, max / Math.max(width, height), Math.sqrt(pixels / (width * height))); width = Math.max(1, Math.round(width * scale)); height = Math.max(1, Math.round(height * scale));
- if (width * height > pixels) { width = Math.max(1, Math.floor(width * scale)); height = Math.max(1, Math.floor(height * scale)); }
+ const scale = Math.min(1, max / Math.max(width, height), Math.sqrt(pixels / (width * height))), natural = { width, height };
+ width = Math.max(1, Math.round(width * scale)); height = Math.max(1, Math.round(height * scale));
+ // Rounding up can overshoot the pixel limit by a few pixels: round the scaled size down instead (from
+ // the picture's own size, not the already scaled one), and trim a pixel from the longer side if still over.
+ if (width * height > pixels) { width = Math.max(1, Math.floor(natural.width * scale)); height = Math.max(1, Math.floor(natural.height * scale)); }
+ while (width * height > pixels && Math.max(width, height) > 1) { if (width >= height) width--; else height--; }
const canvas = Object.assign(document.createElement('canvas'), { width, height });
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
const encoded = await new Promise((resolve, reject) => canvas.toBlob(b => b ? resolve(b) : reject(new Error('The picture could not be converted.')), type, quality));
@@ -83,14 +88,21 @@ 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; };
+ /** 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: documentProperties(pkg()?.parts || {}), fileName: /\.\w+$/.test(docName() || '') ? docName() : `${docName() || 'Document'}.docx`, user: { name: user() || '' },
- pages: engine?.layout?.pageCount, page: (engine?.caretPageIndex ?? 0) + 1 };
+ return { now: new Date(), properties: packageDocumentProperties(pkg()), fileName: /\.\w+$/.test(docName() || '') ? docName() : `${docName() || 'Document'}.docx`, user: { name: user() || '' },
+ pages: engine?.layout?.pageCount, ...caretPageNumber(engine) };
};
setFieldContextProvider(fieldContext);
@@ -242,7 +254,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 +269,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) {
@@ -367,7 +385,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);
@@ -404,6 +422,8 @@ export function createInsert({ editor, pkg, error, base = {}, docName = () => ''
engine.finishComposition();
const page = engine.session.path !== null ? engine.storyPage ?? 0 : engine.caretPageIndex ?? 0, section = engine.sectionOfPage(page);
openFormatPageNumbers({ current: engine.pageStories?.sections?.[section]?.pageNumbering || {}, onClose: () => queueMicrotask(() => editor()?.focus()), onApply: ({ format, start }) => {
+ // Page numbering is a section setting no protected edit check sees: refused under Restrict Editing.
+ if (base.protectedDocument?.()) throw new Error('This document is protected against editing, so its page numbering cannot be changed. Stop protection under Review > Restrict Editing first.');
const value = format === 'decimal' && start === null ? null : { format, ...(start !== null ? { start } : {}) };
pkg().setPageNumbering(section, value);
engine.setStoryOptions({ section, pageNumbering: value });
@@ -412,8 +432,11 @@ export function createInsert({ editor, pkg, error, base = {}, docName = () => ''
}
function removePageNumbers() {
const engine = ready(); if (!engine) return;
+ // The section of the header or footer being edited, else of the caret: the caret's last place in the text may be
+ // in another section.
+ const page = engine.session.path !== null ? engine.storyPage ?? 0 : engine.caretPageIndex ?? 0;
toBody(engine);
- const page = engine.caretPageIndex ?? 0, section = engine.pageStories?.sections?.[engine.sectionOfPage(page)];
+ const section = engine.pageStories?.sections?.[engine.sectionOfPage(page)];
const paths = [...new Set(['header', 'footer'].flatMap(kind => Object.values(section?.[kind] || {})))].filter(path => engine.pageStories.stories[path]?.editable);
let removed = 0;
for (const path of paths) {
@@ -516,14 +539,14 @@ 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?.();
} });
}
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 +581,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 },
];
}
@@ -591,7 +614,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') : '';
}
@@ -619,6 +642,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/src/views/paragraph-dialog.js b/src/documents/src/views/paragraph-dialog.js
index c3d3a8415..325ade772 100644
--- a/src/documents/src/views/paragraph-dialog.js
+++ b/src/documents/src/views/paragraph-dialog.js
@@ -1,7 +1,7 @@
import { el } from '../ui/dom.js';
import { tabStrip, field, checkbox, group, select, distance, points, openFormatDialog, paragraphPreview } from './format-dialog-kit.js';
-const ALIGNMENTS = [['left', 'Left'], ['center', 'Centered'], ['right', 'Right'], ['both', 'Justified']];
+const ALIGNMENTS = [['left', 'Left'], ['center', 'Centered'], ['right', 'Right'], ['both', 'Justified'], ['distribute', 'Distributed']];
const OUTLINE = [...Array.from({ length: 9 }, (_, i) => [String(i), `Level ${i + 1}`]), ['9', 'Body text']];
const LINE_CHOICES = [['single', 'Single'], ['oneHalf', '1.5 lines'], ['double', 'Double'], ['atLeast', 'At least'], ['exact', 'Exactly'], ['multiple', 'Multiple']];
@@ -81,7 +81,8 @@ export function openParagraphDialog({ format: f, unit = 'in', canSetOutline = tr
const s = sample.sample.style, scale = 1 / 1440 * 40; // 40px per inch in the miniature page
const twips = input => { const v = input.read?.(); return v ?? 0; };
const align = alignment.value || 'left';
- s.textAlign = align === 'both' ? 'justify' : align;
+ s.textAlign = align === 'both' || align === 'distribute' ? 'justify' : align;
+ s.textAlignLast = align === 'distribute' ? 'justify' : '';
const leftTw = twips(left), rightTw = twips(right);
s.marginLeft = `${Math.max(-20, leftTw * scale)}px`; s.marginRight = `${Math.max(-20, rightTw * scale)}px`;
const first = special.value === 'firstLine' ? twips(by) : special.value === 'hanging' ? -twips(by) : 0;
diff --git a/src/documents/src/views/pictures.js b/src/documents/src/views/pictures.js
index c4c70abef..da9a084e2 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, 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';
@@ -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);
@@ -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 () => {
@@ -394,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/src/views/proofing.js b/src/documents/src/views/proofing.js
index bf07fac16..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.
@@ -16,6 +17,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 +76,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,12 +156,15 @@ 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); } });
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); }
@@ -161,7 +189,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);
@@ -189,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),
@@ -241,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 },
@@ -259,10 +287,10 @@ 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) { 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/src/views/read-mode.js b/src/documents/src/views/read-mode.js
index 6a158ee6e..a41dc87d5 100644
--- a/src/documents/src/views/read-mode.js
+++ b/src/documents/src/views/read-mode.js
@@ -14,10 +14,15 @@ import { readingColumns, lineFocusBand } from '../engine/document-views.js';
const WIDTHS = [['narrow', 'Narrow'], ['default', 'Default'], ['wide', 'Wide']];
const COLORS = [['none', 'None'], ['sepia', 'Sepia'], ['inverse', 'Inverse']];
const FOCUS = [[0, 'Off'], [1, 'One Line'], [3, 'Three Lines'], [5, 'Five Lines']];
+// Room beside each screen's last page for the comment cards (review.js puts them there), while they are shown.
+const COMMENT_ROOM = 312;
const letterSpacing = () => typeof CanvasRenderingContext2D !== 'undefined' && 'letterSpacing' in CanvasRenderingContext2D.prototype;
export function createReadMode({ editor, host, root: rootOf, exit, toggleNavigation, readAloud, readAloudActive, documentTitle }) {
let active = false, width = 'default', color = 'none', textSpacing = false, focusLines = 0, focusLine = null, screen = 0, perScreen = 1, columnGap = 32;
+ let commentRoom = 0;
+ // Comments shown or hidden change the room the reading columns have.
+ const markup = typeof MutationObserver === 'undefined' ? { observe() {}, disconnect() {} } : new MutationObserver(() => { if (active && commentRoom !== (host.classList.contains('has-comment-markup') ? COMMENT_ROOM : 0)) apply(); });
let restoreZoom = null, wheelAt = 0, wheelTotal = 0, textInput = null, textInputTabIndex = null;
const title = el('span.read-title');
const counter = el('span.read-counter', { role: 'status', 'aria-live': 'polite' });
@@ -52,12 +57,15 @@ export function createReadMode({ editor, host, root: rootOf, exit, toggleNavigat
function apply({ keepPosition = true } = {}) {
const ed = engine(); if (!ed) return;
const anchor = keepPosition ? screen * perScreen : 0;
- const columns = readingColumns(host.clientWidth, host.clientHeight - 8, width, ed.zoom);
+ const room = host.classList.contains('has-comment-markup') ? COMMENT_ROOM : 0;
+ commentRoom = room;
+ const columns = readingColumns(host.clientWidth - room, host.clientHeight - 8, width, ed.zoom);
perScreen = columns.count; columnGap = columns.gap;
+ host.dataset.cardPages = String(perScreen);
ed.setView({ kind: 'read', width: Math.floor(columns.width), height: Math.floor(columns.height), color, textSpacing: textSpacing ? { character: letterSpacing() ? 0.6 : 0 } : false });
host.style.setProperty('--read-gap', `${columnGap}px`);
const pageWidth = Math.floor(columns.width) * ed.zoom;
- host.style.setProperty('--read-inset', `${Math.max(0, Math.floor((host.clientWidth - (perScreen * pageWidth + (perScreen - 1) * columnGap)) / 2))}px`);
+ host.style.setProperty('--read-inset', `${Math.max(0, Math.floor((host.clientWidth - room - (perScreen * pageWidth + (perScreen - 1) * columnGap)) / 2))}px`);
host.classList.toggle('read-inverse', color === 'inverse');
host.classList.toggle('read-sepia', color === 'sepia');
screen = Math.min(screens() - 1, Math.floor(anchor / perScreen));
@@ -187,6 +195,7 @@ export function createReadMode({ editor, host, root: rootOf, exit, toggleNavigat
// handling would edit (Backspace, Alt+Shift+Arrow, Ctrl+V). Reading keeps focus on the pages.
host.addEventListener('focusin', e => { if (e.target.matches?.('.canvas-input')) focusSurface(); }, { signal: keyAbort.current.signal });
resize.observe(host);
+ markup.observe(host, { attributes: true, attributeFilter: ['class'] });
apply();
// The screen holding the caret, once the reading pages exist.
screen = Math.min(screens() - 1, Math.floor((ed.caretPageIndex ?? 0) / perScreen)); show(false);
@@ -197,7 +206,8 @@ export function createReadMode({ editor, host, root: rootOf, exit, toggleNavigat
if (!active) return null;
active = false;
const ed = engine();
- keyAbort.current?.abort(); resize.disconnect();
+ keyAbort.current?.abort(); resize.disconnect(); markup.disconnect();
+ delete host.dataset.cardPages;
root.classList.remove('ed-read-mode', 'read-line-focus');
host.classList.remove('is-reading', 'read-inverse', 'read-sepia');
host.removeAttribute('tabindex'); host.setAttribute('aria-label', 'Document pages');
diff --git a/src/documents/src/views/references-citations.js b/src/documents/src/views/references-citations.js
index 24b8627f6..161de78b4 100644
--- a/src/documents/src/views/references-citations.js
+++ b/src/documents/src/views/references-citations.js
@@ -49,6 +49,18 @@ export function createCitationCommands(ctx) {
fields.updateCitations(body, sources, style); fields.updateBibliographies(body, sources, style); return true;
}, { modules: ['bibliography'], refresh: false });
}
+ /**
+ * The citation and bibliography refresh as an action for runBody's bibliography module, with the document's
+ * sources and style read now, so Update Fields does it in the same step as its other fields.
+ */
+ async function refreshAction() {
+ if (!modules) modules = await engineModules();
+ const { data, style } = documentData();
+ return (body, fields) => {
+ if (!body.paragraphs.some(p => p.fieldMarkers?.some(m => ['CITATION', 'BIBLIOGRAPHY'].includes(m.field.type)))) return;
+ fields.updateCitations(body, data.sources, style); fields.updateBibliographies(body, data.sources, style);
+ };
+ }
/** Save a document source edited in the source dialog; a changed tag takes its citations along. */
const saveEditedSource = (source, draft) => saveSources(documentData().data.sources.map(s => s.tag === source.tag ? draft : s), currentStyle(),
{ renamed: draft.tag.toLowerCase() !== source.tag.toLowerCase() ? [source.tag, draft.tag] : null });
@@ -351,5 +363,5 @@ export function createCitationCommands(ctx) {
{ label: 'Update Citations and Bibliography', icon: 'updateFields', onSelect: () => refreshFields() }];
}
- return { citationItems, bibliographyItems, manageSources, styleControl, setStyle, currentStyle, refreshFields, insertBibliography, cite, sourceDialog, editCitation, contextItems, citationAt, documentData };
+ return { citationItems, bibliographyItems, manageSources, styleControl, setStyle, currentStyle, refreshFields, refreshAction, insertBibliography, cite, sourceDialog, editCitation, contextItems, citationAt, documentData };
}
diff --git a/src/documents/src/views/references-notes.js b/src/documents/src/views/references-notes.js
index 551dc153f..50a2b4a6f 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 || {};
@@ -68,8 +69,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.
@@ -122,6 +124,7 @@ export function createNoteCommands(ctx) {
function applySettings(kind, values) {
const engine = ready(); if (!engine) return;
+ ctx.unprotected?.('its footnote and endnote settings');
pkg().setNoteSettings(kind, values);
const definition = engine.session.definition, key = settingsKey(kind);
if (definition?.[key]) {
@@ -147,6 +150,17 @@ export function createNoteCommands(ctx) {
const start = number('Start at', settings[kind].start, { min: 1, max: 32767, oninput: () => remember() });
const restart = select('Numbering', [], null, () => remember());
const pending = { footnote: { ...settings.footnote }, endnote: { ...settings.endnote } };
+ // What the document has now: only settings that differ from it are written, so Apply with nothing changed
+ // leaves settings.xml as it was, and a footnote change adds no endnote settings.
+ const applied = { footnote: { ...settings.footnote }, endnote: { ...settings.endnote } };
+ const applyChanged = () => {
+ for (const k of ['footnote', 'endnote']) {
+ const changed = Object.fromEntries(['format', 'start', 'restart', 'position'].filter(key => pending[k][key] !== applied[k][key]).map(key => [key, pending[k][key]]));
+ if (!Object.keys(changed).length) continue;
+ applySettings(k, changed);
+ applied[k] = { ...applied[k], ...changed };
+ }
+ };
function remember() {
if (!restart.options.length) return; // Not filled in yet.
pending[kind] = { ...pending[kind], format: format.value, start: Math.max(1, Math.min(32767, Math.round(Number(start.value)) || 1)), restart: restart.value, position: kind === 'footnote' ? footPosition.value : endPosition.value };
@@ -160,7 +174,7 @@ export function createNoteCommands(ctx) {
}
switchKind(kind);
const convert = el('button.btn.btn-outline', { type: 'button', onclick: () => convertDialog() }, 'Convert…');
- const apply = el('button.btn.btn-outline', { type: 'button', onclick: () => { if (!dialog.form.reportValidity()) return; remember(); try { for (const k of ['footnote', 'endnote']) applySettings(k, pending[k]); dialog.showError(''); } catch (e) { dialog.showError(e.message); } } }, 'Apply');
+ const apply = el('button.btn.btn-outline', { type: 'button', onclick: () => { if (!dialog.form.reportValidity()) return; remember(); try { applyChanged(); dialog.showError(''); } catch (e) { dialog.showError(e.message); } } }, 'Apply');
const dialog = openDialog({ title: 'Footnote and Endnote', className: 'ref-notes-dialog', submitLabel: 'Insert', actions: [apply], body: [
el('fieldset.ref-section', {}, el('legend', {}, 'Location'),
el('div.ref-location-row', {}, el('label.ed-check', {}, footRadio, 'Footnotes'), footPosition),
@@ -173,7 +187,7 @@ export function createNoteCommands(ctx) {
el('p.field-hint', {}, 'Changes apply to the whole document.'),
], onSubmit: async () => {
remember();
- for (const k of ['footnote', 'endnote']) applySettings(k, pending[k]);
+ applyChanged();
await insertNote(kind, { customMark: custom.value });
} });
}
diff --git a/src/documents/src/views/references-toc.js b/src/documents/src/views/references-toc.js
index dcbe15213..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'] }) },
];
@@ -128,11 +130,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/src/views/references.js b/src/documents/src/views/references.js
index 97e4f3740..7ee8f245b 100644
--- a/src/documents/src/views/references.js
+++ b/src/documents/src/views/references.js
@@ -27,13 +27,24 @@ async function pageField(type) {
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(); };
+ /**
+ * New header and footer parts and document settings bypass the protection check every edit passes through:
+ * under Restrict Editing they are refused, as typing is.
+ */
+ const unprotected = what => { if (base.protectedDocument?.()) throw new Error(`This document is protected against editing, so ${what} cannot be changed. Stop protection under Review > Restrict Editing first.`); };
- /** 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,12 +53,13 @@ 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.`);
engine.editStory(existing, page); return existing;
}
+ unprotected(`its ${kind}s`);
const { TextDocument, paragraph } = await import('../engine/model.js');
const body = engine.session.body;
const styleId = Object.values(body.styles.definitions || {}).find(s => s.type === 'paragraph' && s.name?.toLowerCase() === kind)?.id;
@@ -61,6 +73,7 @@ export function createReferences({ editor, pkg, error, base = {}, onChange = ()
async function setStoryOption(option, on) {
try {
const engine = ready(); if (!engine) return;
+ unprotected('its headers and footers');
const kind = activeStoryKind(), page = currentPage(engine), section = engine.sectionOfPage(page);
if (option === 'first') { pkg().setTitlePage(section, on); engine.setStoryOptions({ section, firstPageDifferent: on }); }
else { pkg().setEvenAndOdd(on); engine.setStoryOptions({ evenAndOdd: on }); }
@@ -127,12 +140,18 @@ export function createReferences({ editor, pkg, error, base = {}, onChange = ()
* then refresh page references (all, or `result.only`). Returns the action's result. With `track`, text the
* action inserts (a caption, a cross-reference) is then a tracked insertion when Track Changes is on.
*/
- async function runBody(action, { modules = ['captions'], refresh = true, keepFocus = false, track = false } = {}) {
+ async function runBody(action, { modules = ['captions'], refresh = true, keepFocus = false, track = false, stay = null } = {}) {
const engine = ready(); if (!engine) return false;
engine.finishComposition();
- if (engine.session.path !== null) engine.editStory(null);
const [refreshModule, ...loaded] = await Promise.all([import('../engine/page-reference-refresh.js'), ...modules.map(name => ENGINE[name]())]);
if (!pageLookupModule) pageLookupModule = await import('../engine/page-lookup.js');
+ if (!ready()) return false;
+ // From here to the end nothing waits, so no keystroke can arrive while the body is the open story. With `stay`,
+ // a header, footer or note open now is opened again with its caret where it is, and `stay(engine)` runs there:
+ // keys typed while the modules loaded went into it, and keys typed after go into it too.
+ engine.finishComposition();
+ const story = stay && engine.session.path !== null ? { path: engine.session.path, page: engine.storyPage, ...engine.model.selection } : null;
+ if (engine.session.path !== null) engine.editStory(null);
const body = engine.session.body, tracking = body.trackChanges;
body.trackChanges = false;
let result;
@@ -149,12 +168,18 @@ export function createReferences({ editor, pkg, error, base = {}, onChange = ()
if (refresh || result?.only) refreshModule.refreshPageReferences(body, model => engine.layoutModel(model), engine.pageStories, { checkpoint: false, only: result?.only || null });
return result;
});
- } finally { body.trackChanges = tracking; }
+ } finally {
+ body.trackChanges = tracking;
+ if (story && engine.session.path === null && engine.editStory(story.path, story.page) && engine.session.path === story.path) {
+ engine.model.select(story.anchor, story.focus);
+ stay(engine);
+ }
+ }
if (!keepFocus) engine.focus();
onChange();
return result;
}
- const ctx = { editor, pkg, error, ready, fail, runBody, pageOf, onChange: () => onChange(), showFieldCodes: () => base.showFieldCodes?.() };
+ const ctx = { editor, pkg, error, ready, fail, runBody, pageOf, unprotected, onChange: () => onChange(), showFieldCodes: () => base.showFieldCodes?.() };
const toc = createTocCommands(ctx);
const notes = createNoteCommands(ctx);
const citations = createCitationCommands(ctx);
@@ -178,12 +203,27 @@ 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 { 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 (issues.length) error(new Error([...new Set(issues.map(issue => issue.message))].join(' ')));
+ const engine = ready(); if (!engine) return;
+ engine.finishComposition();
+ engine.fieldUpdateRunning = true;
+ try {
+ const [{ updateSequenceFields }, { updateStoryFields, fieldEvaluationContext }] = await Promise.all([import('../engine/field-update.js'), import('../engine/field-results.js')]);
+ const issues = [], notesMaps = await noteNumberMaps(), citationsUpdate = await citations.refreshAction();
+ // Pressed in a header, footer or note, F9 updates the body's fields and stays there (runBody's `stay`): it
+ // used to leave the caret at the start of the body, and keys typed while it worked went into the body.
+ await runBody((body, fields) => {
+ updateSequenceFields(body, { onIssue: issue => issues.push(issue), notes: notesMaps });
+ citationsUpdate?.(body, fields);
+ return true;
+ }, { modules: ['bibliography'], stay: open => {
+ // The header's or footer's own fields (DATE, FILENAME…) update too; page numbers there follow pagination.
+ if (!open.model.paragraphs.some(p => p.runs.some(r => r.field))) return;
+ const { anchor, focus } = open.model.selection;
+ open.execute(() => updateStoryFields(open.model, { ...(fieldEvaluationContext() || {}), text: open.session.body.text }) > 0);
+ open.model.select(anchor, focus); open.revealCaret?.();
+ } });
+ if (issues.length) error(new Error([...new Set(issues.map(issue => issue.message))].join(' ')));
+ } finally { engine.fieldUpdateRunning = false; }
}
/* ---- Captions ---- */
@@ -210,10 +250,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();
} });
@@ -301,14 +341,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' });
@@ -339,8 +380,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() },
@@ -415,6 +456,22 @@ export function createReferences({ editor, pkg, error, base = {}, onChange = ()
close.classList.add('ribbon-wide'); // 'Close Header and Footer' in two lines
for (const b of [header, footer, goHeader, goFooter, close]) b.addEventListener('mousedown', e => e.preventDefault());
when(goHeader, () => activeStoryKind() !== 'header'); when(goFooter, () => activeStoryKind() !== 'footer');
+ // Header and footer distances belong to the section whose header or footer is edited (the body's last
+ // section had them before, so in a document of several sections the change landed on another section).
+ const sectionSettings = engine => { const body = engine?.session.body; return body?.sectionSettings?.[engine.sectionOfPage(currentPage(engine))] || body?.pageSettings; };
+ const shown = key => String(Math.round((sectionSettings(editor())?.[key] ?? 720) / IN * 100) / 100);
+ function setDistance(key, value) {
+ const engine = ready(); if (!engine) return;
+ engine.finishComposition();
+ const story = engine.session.path, page = currentPage(engine), section = engine.sectionOfPage(page), { anchor, focus } = engine.model.selection;
+ // Sections are the body's: apply there, then return to the header or footer with the caret where it was.
+ if (story !== null) engine.editStory(null);
+ let failure = null;
+ engine.execute(() => engine.model.setPageSettings({ [key]: value }, { scope: 'section', section }), e => { failure = e; }, undefined, true);
+ if (story !== null && engine.editStory(story, page) && engine.session.path === story) engine.model.select(anchor, focus);
+ if (failure) fail(failure);
+ onChange();
+ }
const distance = (key, label) => {
const input = document.createElement('input');
Object.assign(input, { type: 'number', step: 0.05, min: 0, max: 22, className: 'canvas-spacing ribbon-number' });
@@ -423,14 +480,14 @@ export function createReferences({ editor, pkg, error, base = {}, onChange = ()
// The arrows step by 0.05, but any distance in range is accepted, as Page Setup accepts it.
const v = input.validity;
const engine = ready(); if (!engine || input.value === '' || v.badInput || v.rangeOverflow || v.rangeUnderflow) { input.reportValidity(); return; }
- try { engine.finishComposition(); engine.setPageSettings({ [key]: Math.round(Number(input.value) * IN) }); } catch (e) { fail(e); }
+ try { setDistance(key, Math.round(Number(input.value) * IN)); } catch (e) { fail(e); }
});
input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); input.dispatchEvent(new Event('change')); editor()?.focus(); } });
when(input, () => !!ready());
- kit.states?.push?.({ control: input, enabled: () => !!ready(), value: () => Math.round((editor()?.session.body.pageSettings[key] ?? 720) / IN * 100) / 100 });
+ kit.states?.push?.({ control: input, enabled: () => !!ready(), value: () => Number(shown(key)) });
const wrap = document.createElement('label'); wrap.className = 'ribbon-spin'; wrap.title = label;
wrap.append(commandIconEl(key === 'header' ? 'spaceBefore' : 'spaceAfter', 16), Object.assign(document.createElement('span'), { className: 'ribbon-spin-label', textContent: label }), input, Object.assign(document.createElement('span'), { className: 'ribbon-spin-unit', textContent: 'in' }));
- input.value = String(Math.round((editor()?.session.body.pageSettings[key] ?? 720) / IN * 100) / 100);
+ input.value = shown(key);
return { wrap, input };
};
const top = distance('header', 'Header from Top'), bottom = distance('footer', 'Footer from Bottom');
@@ -439,7 +496,7 @@ export function createReferences({ editor, pkg, error, base = {}, onChange = ()
const definition = () => editor()?.pageStories;
const firstPage = kit.check('Different First Page', () => !!definition()?.sections[editor()?.sectionOfPage(currentPage(editor())) ?? 0]?.firstPageDifferent, on => setStoryOption('first', on));
const oddEven = kit.check('Different Odd & Even Pages', () => !!definition()?.evenAndOdd, on => setStoryOption('evenOdd', on));
- const sync = () => { const p = editor()?.session.body.pageSettings; if (!p) return; for (const [d, key] of [[top, 'header'], [bottom, 'footer']]) if (document.activeElement !== d.input) d.input.value = String(Math.round(p[key] / IN * 100) / 100); };
+ const sync = () => { if (!editor()) return; for (const [d, key] of [[top, 'header'], [bottom, 'footer']]) if (document.activeElement !== d.input) d.input.value = shown(key); };
when(close, () => { sync(); return true; });
return [{ id: 'headerFooter', label: 'Header & Footer', contextual: 'headerFooter', selectOnAppear: true, groups: [
{ id: 'hfHeaderFooter', label: 'Header & Footer', icon: 'header', priority: 6, items: [header, footer, pageNumber] },
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 5bd36dc95..f55f0880a 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() {
@@ -180,7 +183,8 @@ export function createReviewTools({ editor, pkg, error, author, review, proofing
{ label: 'Translate', icon: 'translate', disabled: !has, items: commands.translateItems() },
{ label: 'Language…', icon: 'language', disabled: !ready(), onSelect: languageDialog },
{ separator: true },
- { label: 'Word Count…', icon: 'wordCount', shortcut: `${MOD_KEY}+Shift+G`, disabled: !has, onSelect: commands.wordCount },
+ // On a Mac Cmd+Shift+G is Find Previous (editor.js), so Word Count has its chord only elsewhere.
+ { label: 'Word Count…', icon: 'wordCount', shortcut: MOD_KEY === 'Cmd' ? null : 'Ctrl+Shift+G', disabled: !has, onSelect: commands.wordCount },
{ separator: true },
{ label: 'Check Accessibility', icon: 'accessibility', disabled: !has, onSelect: commands.checkAccessibility },
{ separator: true },
@@ -252,7 +256,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;
@@ -264,6 +269,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 288a426e9..0dff6435b 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;
@@ -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 = {} }) {
@@ -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 ---- */
@@ -55,12 +59,13 @@ 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);
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 };
@@ -127,6 +132,18 @@ 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);
+ // 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; };
const me = () => { const name = author() || 'Author'; return { author: name, initials: initialsFor(name) }; };
/**
@@ -175,13 +192,14 @@ 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;
+ 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());
@@ -198,17 +216,18 @@ 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;
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);
@@ -226,16 +245,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++;
@@ -263,6 +284,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 })
@@ -274,21 +301,28 @@ 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);
- 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;
// 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,
};
@@ -304,15 +338,31 @@ 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 }) {
- const input = el('textarea.wcc-input', { rows: 1, placeholder: `${placeholder} (type @ to mention someone)`, 'aria-label': placeholder, 'aria-autocomplete': 'list' }, value);
+ 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;
+ // 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);
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;
@@ -320,25 +370,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(); });
- input.addEventListener('blur', () => { mentions.hidden = true; });
+ input.addEventListener('input', () => { submit.disabled = !input.value.trim(); grow(); showMentions(); if (key) unsent.set(key, input.value); });
+ 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?.(); }
@@ -347,7 +398,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()); } },
@@ -357,7 +408,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; 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);
@@ -373,9 +424,9 @@ 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({ placeholder: 'Reply', submitLabel: 'Reply',
- onSubmit: text => { run(() => editor().model.replyToComment(thread.id, { ...me(), text })); refresh(true); },
+ 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'));
return node;
@@ -407,9 +458,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(); } });
@@ -443,6 +499,14 @@ export function createReview({ editor, pkg, host, error, author, onChange = () =
function place() {
const engine = editor(); if (!engine || !layer.isConnected) return;
const items = [...layer.querySelectorAll('.ed-comment-card')];
+ // Cards narrow (to 200 pixels) where the window leaves less room beside the pages, so the column does not run
+ // off its side; pages laid across keep a full-width column after their groups.
+ const scroller = host(), across = Number(scroller?.dataset.cardPages) || 0;
+ if (scroller) {
+ const pageWidth = Math.max(0, ...(engine.canvases || []).map(canvas => canvas.offsetWidth)), inset = parseFloat(getComputedStyle(engine.pages).paddingLeft) || 0;
+ const width = across ? 288 : Math.round(Math.max(200, Math.min(288, scroller.clientWidth - inset - pageWidth - 44)));
+ if (scroller.style.getPropertyValue('--comment-card-width') !== `${width}px`) scroller.style.setProperty('--comment-card-width', `${width}px`);
+ }
// Look every card's thread up in one pass: a lookup per card made each keystroke slow in a much-reviewed document.
const byId = new Map(threads().map(t => [t.id, t]));
const anchored = items.map(node => {
@@ -450,21 +514,32 @@ export function createReview({ editor, pkg, host, error, author, onChange = () =
const start = node.dataset.anchor !== undefined ? Number(node.dataset.anchor) : id ? thread?.start : draft?.start;
return { node, geometry: start === null || start === undefined ? null : engine.positionGeometry(start), active: node.classList.contains('is-active') };
}).filter(item => item.geometry).sort((a, b) => a.geometry.top - b.geometry.top);
+ // Pages laid out across (Read Mode screens, Side to Side) leave room for the cards after every `across` pages:
+ // a card goes beside the last page of its group, and each group's cards stack apart from the others'.
+ const canvases = engine.canvases || [];
+ const columnRight = item => { const page = canvases[Math.min(canvases.length - 1, (Math.floor(item.geometry.page / across) + 1) * across - 1)]; return page ? page.offsetLeft + page.offsetWidth : item.geometry.pageRight; };
const right = Math.max(0, ...anchored.map(item => item.geometry.pageRight));
- // The active card keeps its anchor's level; neighbours make room above and below it.
- const activeIndex = anchored.findIndex(item => item.active);
- const heights = anchored.map(item => item.node.offsetHeight || 80), tops = anchored.map(item => item.geometry.top - 8);
- const gap = 8;
- 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);
- anchored.forEach((item, i) => {
- item.node.style.top = `${Math.max(8, tops[i])}px`;
- item.node.style.left = `${right + 16}px`;
- item.node.style.display = '';
- });
+ const groups = new Map();
+ for (const item of anchored) { const x = across ? columnRight(item) : right; if (!groups.has(x)) groups.set(x, []); groups.get(x).push(item); }
+ for (const [x, column] of groups) {
+ // The active card keeps its anchor's level; neighbours make room above and below it.
+ const activeIndex = column.findIndex(item => item.active);
+ const heights = column.map(item => item.node.offsetHeight || 80), tops = column.map(item => item.geometry.top - 8);
+ const gap = 8;
+ if (activeIndex >= 0) {
+ for (let i = activeIndex + 1; i < column.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);
+ }
+ // 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 (column.length) tops[0] = Math.max(8, tops[0]);
+ for (let i = 1; i < column.length; i++) tops[i] = Math.max(tops[i], tops[i - 1] + heights[i - 1] + gap);
+ column.forEach((item, i) => {
+ item.node.style.top = `${tops[i]}px`;
+ item.node.style.left = `${x + 16}px`;
+ item.node.style.display = '';
+ });
+ }
const placed = new Set(anchored.map(item => item.node));
for (const node of items) if (!placed.has(node)) node.style.display = 'none';
}
@@ -475,7 +550,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');
}
@@ -485,6 +560,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();
@@ -498,12 +574,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(), 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;
@@ -513,8 +589,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.
@@ -533,7 +609,7 @@ export function createReview({ editor, pkg, host, error, author, onChange = () =
engine.setMarginBars?.(['all', 'simple'].includes(revisionView.mode) && o.changeBars !== 'none' ? shownChanges(body.revisions) : [], barColor, o.changeBars === 'none' ? 'left' : o.changeBars || 'left');
const label = markupButton?.querySelector('.ribbon-small-label'), name = MARKUP_VIEWS.find(([mode]) => mode === revisionView.mode)?.[1];
if (label && name && label.textContent !== name) label.textContent = name;
- const key = `${engine.layout?.pageCount}:${engine.zoom}:${engine.model.revision}:${engine.session.body.revision}:${layer.childElementCount}:${scroller.clientWidth}:${activeId}`;
+ const key = `${scroller.className}:${scroller.dataset.cardPages}:${engine.view?.width}:${engine.layout?.pageCount}:${engine.zoom}:${engine.model.revision}:${engine.session.body.revision}:${layer.childElementCount}:${scroller.clientWidth}:${activeId}`;
if (visible && key !== positionsKey) { positionsKey = key; place(); }
}
@@ -560,17 +636,17 @@ 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());
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(show, () => !!editor()?.model.comments, () => String(commentsShown));
- when(showSplit.arrow, () => !!editor()?.model.comments);
+ when(resolve, () => annotating() && !!(commands.activeThread() || threadAtCaret()));
+ 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') },
@@ -632,8 +708,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/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/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/src/views/style-editor.js b/src/documents/src/views/style-editor.js
index b239f43f8..ae47e6ae8 100644
--- a/src/documents/src/views/style-editor.js
+++ b/src/documents/src/views/style-editor.js
@@ -42,7 +42,7 @@ export function openStyleEditor(style, choices, apply, defaultRun = {}) {
characterSpacing: register('run', 'characterSpacing', 'Style character spacing (pt)', style.run?.characterSpacing),
baselineShift: register('run', 'baselineShift', 'Style baseline shift (pt)', style.run?.baselineShift),
capitalization: register('run', 'capitalization', 'Style capitalization', style.run?.capitalization, ['caps', 'capsOff', 'smallCaps', 'smallCapsOff'].map(value => { const kind = value.startsWith('smallCaps') ? 'smallCaps' : 'caps', on = !value.endsWith('Off'); return [value, `${kind === 'caps' ? 'All caps' : 'Small caps'} ${on ? 'on' : 'off'}${character ? ` (${on === (defaultRun.capitalization === kind) ? 'keep' : 'reverse'})` : ''}`]; }).concat(retainedCaps)),
- strikethrough: register('run', 'strikethrough', 'Style strikethrough', style.run?.strikethrough, [['single', character ? `Single on (${defaultRun.strikethrough === 'single' ? 'keep' : 'reverse'})` : 'Single on'], ['singleOff', character ? `Single off (${defaultRun.strikethrough === 'single' ? 'reverse' : 'keep'})` : 'Single off'], ['double', 'Double on'], ['doubleOff', 'Double off']]),
+ strikethrough: register('run', 'strikethrough', 'Style strikethrough', style.run?.strikethrough, [['single', character ? `Single on (${defaultRun.strikethrough === 'single' ? 'keep' : 'reverse'})` : 'Single on'], ['singleOff', character ? `Single off (${defaultRun.strikethrough === 'single' ? 'reverse' : 'keep'})` : 'Single off'], ['double', 'Double on'], ['doubleOff', 'Double off'], ['bothStrikeOff', 'Single and double off'], ['singleStrikeDoubleOff', 'Single on, double off'], ['doubleStrikeSingleOff', 'Double on, single off']]),
color: register('run', 'color', 'Style text color', style.run?.color),
verticalAlign: register('run', 'verticalAlign', 'Style text position', style.run?.verticalAlign, [['baseline', 'Baseline'], ['superscript', 'Superscript'], ['subscript', 'Subscript']]),
underlineStyle: register('run', 'underlineStyle', 'Style underline', style.run?.underlineStyle ?? (style.run?.underline === undefined ? undefined : style.run.underline ? 'single' : 'none'), Object.entries(UNDERLINE_STYLES)),
@@ -50,7 +50,7 @@ export function openStyleEditor(style, choices, apply, defaultRun = {}) {
...Object.fromEntries([['bold', 'Style bold'], ['italic', 'Style italic']].map(([key, label]) => [key, register('run', key, label, style.run?.[key], character && key !== 'underline'
? toggles.map(([value, label]) => [value, `${label} (${(value === 'true') === Boolean(defaultRun[key]) ? 'keep' : 'reverse'})`]) : toggles)])),
};
- const paragraphFields = creating || character ? [] : [register('paragraph', 'alignment', 'Style alignment', style.properties?.alignment, [['left', 'Left'], ['center', 'Center'], ['right', 'Right'], ['both', 'Justified']]),
+ const paragraphFields = creating || character ? [] : [register('paragraph', 'alignment', 'Style alignment', style.properties?.alignment, [['left', 'Left'], ['center', 'Center'], ['right', 'Right'], ['both', 'Justified'], ['distribute', 'Distributed']]),
...lineSpacing.nodes,
...[['spaceBefore', 'before', 'Style space before (pt)'], ['spaceAfter', 'after', 'Style space after (pt)']].map(([key, attr, label]) => register('paragraph', key, label, style.properties?.spacing?.[attr] === undefined ? undefined : Number(style.properties.spacing[attr]) / 20)),
register('paragraph', 'paragraphShading', 'Style paragraph fill', style.properties?.paragraphShading),
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..bffb0c1f3 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,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(p => p.runs.map(r => r.text).join('')).join(' ').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 });
}
@@ -365,7 +371,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/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/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/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', '',
+ 'IgnoredQuarterly report
Sales in the caf=C3=A9 grew by 12 % and kept grow=', 'ing.
', '', '------=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']);
+});
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/bibliography-fields.test.js b/src/documents/tests/bibliography-fields.test.js
index e780e19d1..4dde74c93 100644
--- a/src/documents/tests/bibliography-fields.test.js
+++ b/src/documents/tests/bibliography-fields.test.js
@@ -123,4 +123,18 @@ describe('changing a cited source\'s tag', () => {
expect(d.text).toContain('(Hopper, 2019; Lovelace, 2020)');
expect(strFromU8(unzipSync(await save(pkg, d))['word/document.xml'])).not.toContain('Lov20');
});
+
+ it('puts a citation inserted with the caret inside another citation after it, not inside its result', async () => {
+ const { d } = await setup();
+ d.select(d.text.indexOf('.')); insertCitation(d, { tags: ['Lov20'] }, [book, article], 'apa');
+ const first = citationFields(d.paragraphs)[0];
+ d.select(first.start + 3);
+ insertCitation(d, { tags: ['Hop19'] }, [book, article], 'apa');
+ expect(d.text).toBe('Engines compute (Lovelace, 2020) (Hopper, 2019). Machines learn.');
+ expect(citationFields(d.paragraphs).map(r => [r.citation.tags, r.parentId ?? null])).toEqual([[['Lov20'], null], [['Hop19'], null]]);
+ // Refreshing keeps both.
+ updateCitations(d, [book, article], 'apa');
+ expect(d.text).toBe('Engines compute (Lovelace, 2020) (Hopper, 2019). Machines learn.');
+ });
});
+
diff --git a/src/documents/tests/browser/app-chrome.spec.js b/src/documents/tests/browser/app-chrome.spec.js
index 45ad78e6e..b3fff3e07 100644
--- a/src/documents/tests/browser/app-chrome.spec.js
+++ b/src/documents/tests/browser/app-chrome.spec.js
@@ -505,3 +505,62 @@ test('Enter in the Tabs dialog with no position typed is OK, not an error', asyn
await expect(dialog.getByRole('listbox', { name: 'Tab stops' }).getByRole('option')).toHaveCount(1);
await expect(dialog).toBeVisible();
});
+
+test('Escape leaves a submenu that opened over its own item in a narrow window', async ({ page }) => {
+ await openEditor(page, 390);
+ await page.getByRole('button', { name: 'Menus', exact: true }).click();
+ // The mouse stays where it clicked: on a narrow window the File menu opens over the File item.
+ await page.getByRole('menu', { name: 'Menus' }).getByRole('menuitem', { name: 'File', exact: true }).click();
+ await expect(page.getByRole('menu', { name: 'File', exact: true })).toBeVisible();
+ await page.keyboard.press('Escape');
+ await expect(page.getByRole('menu', { name: 'File', exact: true })).toBeHidden();
+ await expect(page.getByRole('menu', { name: 'Menus' }).getByRole('menuitem', { name: 'File', exact: true })).toBeFocused();
+ // The menu stays closed: the item uncovered under the resting pointer does not open it again.
+ await page.waitForTimeout(400);
+ await expect(page.getByRole('menu', { name: 'File', exact: true })).toBeHidden();
+ await page.keyboard.press('Escape');
+ await expect(page.locator('.menu')).toHaveCount(0);
+});
+
+test('a list of choices in a dialog draws no drop-down arrow', async ({ page }) => {
+ await openEditor(page);
+ const arrows = dialog => dialog.locator('select').evaluateAll(lists => lists.filter(s => s.size > 1 || s.multiple)
+ .map(s => [s.getAttribute('aria-label'), getComputedStyle(s).backgroundImage]));
+ await (await openMenu(page, 'Insert')).getByRole('menuitem', { name: 'Field…' }).click();
+ let dialog = page.getByRole('dialog', { name: 'Field' });
+ await expect(dialog).toBeVisible();
+ expect(await arrows(dialog)).toEqual([['Field names', 'none']]);
+ await page.keyboard.press('Escape');
+ await expect(dialog).toBeHidden();
+ await (await openMenu(page, 'Edit')).getByRole('menuitem', { name: 'Find', exact: true }).click();
+ await page.getByRole('menuitem', { name: 'Go To…' }).click();
+ dialog = page.getByRole('dialog', { name: 'Go To' });
+ await expect(dialog).toBeVisible();
+ expect(await arrows(dialog)).toEqual([['Go to what', 'none']]);
+});
+
+test('command search names the Navigation Pane chord as the platform does', async ({ page }) => {
+ await openEditor(page);
+ const search = page.getByRole('combobox', { name: 'Search for commands' });
+ await search.click(); await search.fill('navigation pane');
+ const option = page.getByRole('listbox', { name: 'Commands' }).getByRole('option', { name: /^Navigation Pane/ }).first();
+ await expect(option.locator('.ed-search-key')).toHaveText(MAC ? 'Cmd+Option+O' : 'Ctrl+Alt+O');
+ // And the chord opens it.
+ await search.press('Escape'); await search.press('Escape');
+ await text(page).press(MAC ? 'Meta+Alt+o' : 'Control+Alt+o');
+ await expect(page.getByLabel('Search document')).toBeFocused();
+});
+
+test('contrast themes show which way the AutoSave and Preferences switches are set', async ({ page }) => {
+ await page.emulateMedia({ forcedColors: 'active' });
+ await openEditor(page);
+ // The knob must stand out from the track it slides along, and the track must change with the setting.
+ const look = locator => locator.evaluate(el => { const track = getComputedStyle(el), knob = getComputedStyle(el, '::after'); return { track: track.backgroundColor, knob: knob.backgroundColor }; });
+ const autosave = page.locator('.ed-autosave .ed-switch');
+ const on = await look(autosave);
+ expect(on.knob).not.toBe(on.track);
+ await page.getByRole('switch', { name: 'AutoSave' }).uncheck({ force: true });
+ const off = await look(autosave);
+ expect(off.knob).not.toBe(off.track);
+ expect(off.track).not.toBe(on.track);
+});
diff --git a/src/documents/tests/browser/assistant-new-chat.spec.js b/src/documents/tests/browser/assistant-new-chat.spec.js
index 06e79b52c..479f6ebb8 100644
--- a/src/documents/tests/browser/assistant-new-chat.spec.js
+++ b/src/documents/tests/browser/assistant-new-chat.spec.js
@@ -122,3 +122,40 @@ test('what the Assistant sends is one radio group that the arrow keys switch', a
await expect(before === 'Selection' ? selection : whole).toBeFocused();
await expect(before === 'Selection' ? selection : whole).toHaveAttribute('aria-checked', 'true');
});
+
+test('a draft for the whole document shows only the suggested text and its actions', 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({ timeout: 20000 });
+ await page.getByRole('textbox', { name: 'Document text' }).fill('Plans for the old project.');
+ await page.locator('.as-title-button').click();
+ const pane = page.getByRole('complementary', { name: 'Assistant pane' });
+ await pane.getByRole('radio', { name: 'Whole document' }).click();
+ const box = pane.getByRole('textbox', { name: 'Message the assistant' });
+ await box.fill('Draft an introduction');
+ await box.press('Enter');
+ const reply = pane.locator('.as-message.is-assistant').last();
+ await expect(reply.getByRole('button', { name: 'Insert at Cursor' })).toBeVisible();
+ // Nothing replaces a selection here, and nothing stands in for that line.
+ expect((await reply.innerText()).split('\n').map(line => line.trim()).filter(Boolean)).toEqual(['SUGGESTED TEXT', 'This draft was written by the offline assistant for: Draft an introduction.', 'Insert at Cursor', 'Copy']);
+});
+
+test('a reply that arrives below the fold is scrolled into view', async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto('/mock.html#/new');
+ await expect(page.getByRole('button', { name: 'Bold', exact: true })).toBeEnabled({ timeout: 20000 });
+ await page.getByRole('textbox', { name: 'Document text' }).fill('Plans for the old project.');
+ await page.getByRole('button', { name: 'Assistant', exact: true }).last().click();
+ const pane = page.getByRole('complementary', { name: 'Assistant pane' });
+ const box = pane.getByRole('textbox', { name: 'Message the assistant' });
+ // The mock repeats the question in its answer, so a long question has a long answer.
+ await box.fill(`What does this say about ${'the old project and its plans, '.repeat(6)}?`);
+ await box.press('Enter');
+ const reply = pane.locator('.as-message.is-assistant').last();
+ await expect(reply).toContainText('offline assistant read');
+ // Shown down to its end, or from its start when it is taller than the conversation.
+ await expect.poll(() => reply.evaluate(node => {
+ const log = node.closest('.as-log').getBoundingClientRect(), box = node.getBoundingClientRect();
+ return box.bottom <= log.bottom + 1 || Math.abs(box.top - log.top) <= 1;
+ })).toBe(true);
+});
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 ');
+});
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');
+});
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..197b2ace5
--- /dev/null
+++ b/src/documents/tests/browser/body-page-number-insert.spec.js
@@ -0,0 +1,47 @@
+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) {
+ // Wide enough that the Insert tab's Text group shows Quick Parts with any system font.
+ await page.setViewportSize({ width: 1440, height: 900 });
+ 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/browser/bookmark-pages.spec.js b/src/documents/tests/browser/bookmark-pages.spec.js
new file mode 100644
index 000000000..bd82e277e
--- /dev/null
+++ b/src/documents/tests/browser/bookmark-pages.spec.js
@@ -0,0 +1,28 @@
+import { test, expect } from '@playwright/test';
+import { onTab } from './helpers/editor-tools.js';
+
+// The Bookmarks dialog names the page a bookmark is on as the page shows its number (ii in a section numbered
+// from ii), and lists the document's bookmarks while a header or footer is open.
+test('the Bookmarks dialog shows printed page numbers and lists the text\'s bookmarks from a footer', 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-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(0, 5); e.addBookmark('Opening'); e.model.select(0); e.focus(); });
+ const open = async () => { await onTab(page, 'insert'); await page.locator('#ribbon-panel-insert').getByRole('button', { name: 'Bookmarks', exact: true }).click(); await expect(page.getByRole('dialog')).toBeVisible(); };
+ const list = () => page.getByRole('listbox', { name: 'Document bookmarks', exact: true }).locator('option');
+ await open();
+ await expect(list()).toHaveText(['Opening · page ii']);
+ await page.getByRole('dialog').getByRole('button', { name: 'Done', exact: true }).click();
+ // From the first page's footer the list (as Go To uses it) still holds the text's bookmarks, and going to one
+ // leaves the footer for the text.
+ await page.evaluate(() => {
+ const e = window.__docs.current.view.engine, line = e.storyLayout.lines.find(l => l.page === 0 && e.pageStories.stories[l.pageStory]?.kind === 'footer');
+ e.editStory(line.pageStory, 0); e.focus();
+ });
+ expect(await page.evaluate(() => window.__docs.current.view.engine.bookmarks.map(b => `${b.name} · page ${b.page}`))).toEqual(['Opening · page ii']);
+ await page.evaluate(() => { const e = window.__docs.current.view.engine; e.goToBookmark(e.bookmarks[0].id); });
+ await expect.poll(() => page.evaluate(() => { const e = window.__docs.current.view.engine; return [e.session.path, e.model.range[0]]; })).toEqual([null, 0]);
+});
diff --git a/src/documents/tests/browser/bookmarks.spec.js b/src/documents/tests/browser/bookmarks.spec.js
index 80df2833e..d939f6971 100644
--- a/src/documents/tests/browser/bookmarks.spec.js
+++ b/src/documents/tests/browser/bookmarks.spec.js
@@ -46,3 +46,46 @@ 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 ·/]);
+});
+
+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');
+});
+
+test('adds a bookmark named in another script from the Bookmarks dialog',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('Глава первая');await choose(input,'Глава');
+ await manager(page);await page.getByRole('textbox',{name:'Bookmark name',exact:true}).fill('Закладка_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([/^Закладка_1 ·/]);
+ await expect(page.getByRole('dialog').getByRole('alert')).toBeHidden();
+});
diff --git a/src/documents/tests/browser/canvas-backing-limit.spec.js b/src/documents/tests/browser/canvas-backing-limit.spec.js
new file mode 100644
index 000000000..88f6d710c
--- /dev/null
+++ b/src/documents/tests/browser/canvas-backing-limit.spec.js
@@ -0,0 +1,41 @@
+import { test, expect } from '@playwright/test';
+
+// Safari on iOS draws nothing in a canvas larger than 4096 × 4096 pixels. A Letter page at 200% on a
+// 3x display would need 4896 × 6336, so its canvas is given fewer device pixels instead. The painted
+// selection must still sit where the pointer finds the same text.
+test.use({ viewport: { width: 1800, height: 900 }, deviceScaleFactor: 3 });
+test('keeps zoomed pages within the canvas pixel limit with painting aligned to the pointer', async ({ page }) => {
+ await page.goto('/playground.html');
+ const input = page.getByRole('textbox', { name: 'Document text' });
+ await expect(input).toHaveValue(/^A small beginning/);
+ await page.getByRole('combobox', { name: 'Zoom', exact: true }).selectOption('2');
+ const canvas = page.locator('canvas.canvas-page').first();
+ await expect.poll(() => canvas.evaluate(c => c.getBoundingClientRect().width)).toBeCloseTo(1632, 0);
+ const size = await canvas.evaluate(c => ({ width: c.width, height: c.height, css: c.getBoundingClientRect().width }));
+ expect(size.width * size.height).toBeLessThanOrEqual(4096 * 4096);
+ expect(size.width / size.css).toBeGreaterThan(2);
+ // Select "small" and find its painted highlight.
+ await input.evaluate(el => { el.focus(); el.setSelectionRange(2, 7); el.dispatchEvent(new Event('select', { bubbles: true })); });
+ // The painted highlight's bounds in CSS pixels, or null while it is not painted yet.
+ const highlight = () => canvas.evaluate(c => {
+ const rows = Math.round(c.height / 3), data = c.getContext('2d').getImageData(0, 0, c.width, rows).data;
+ let left = Infinity, right = -1, top = Infinity, bottom = -1;
+ for (let y = 0; y < rows; y += 2) for (let x = 0; x < c.width; x += 2) {
+ const i = (y * c.width + x) * 4, r = data[i], b = data[i + 2];
+ if (b > 200 && b - r > 20 && r > 150) { left = Math.min(left, x); right = Math.max(right, x); top = Math.min(top, y); bottom = Math.max(bottom, y); }
+ }
+ const scale = c.getBoundingClientRect().width / c.width;
+ return right < 0 ? null : { left: left * scale, right: right * scale, top: top * scale, bottom: bottom * scale };
+ });
+ await expect.poll(highlight).not.toBeNull();
+ const painted = await highlight();
+ // The highlight covers one word on the first line: about 5 letters at 200%.
+ expect(painted.right - painted.left).toBeGreaterThan(40);
+ expect(painted.right - painted.left).toBeLessThan(160);
+ const rect = await canvas.boundingBox();
+ // Separate single clicks (a quick second click would select the word).
+ const at = async x => { await page.waitForTimeout(700); await page.mouse.click(rect.x + x, rect.y + (painted.top + painted.bottom) / 2); return input.evaluate(el => el.selectionStart); };
+ // Just inside the painted highlight's edges the pointer lands at that word's ends.
+ expect(await at(painted.left + 3)).toBe(2);
+ expect(await at(painted.right - 3)).toBe(7);
+});
diff --git a/src/documents/tests/browser/canvas-device-pixel-ratio.spec.js b/src/documents/tests/browser/canvas-device-pixel-ratio.spec.js
index dd6f75e97..2bc9943ae 100644
--- a/src/documents/tests/browser/canvas-device-pixel-ratio.spec.js
+++ b/src/documents/tests/browser/canvas-device-pixel-ratio.spec.js
@@ -91,6 +91,13 @@ for (const ratio of [2, 3]) test.describe(`at a device pixel ratio of ${ratio}`,
const c = editor.canvases[index], r = c.getBoundingClientRect();
return {left: r.left, top: r.top, width: r.width * devicePixelRatio, height: r.height * devicePixelRatio, backing: [c.width, c.height]};
}, {zoom, index});
+ // A page over the 4096 × 4096 canvas limit (A4 at 170% on a 3x display) is given fewer device
+ // pixels so it still paints in Safari on iOS (canvas-backing.js); it cannot be pixel for pixel.
+ if (box.width * box.height > 4096 * 4096) {
+ expect(box.backing[0] * box.backing[1], `zoom ${zoom}`).toBeLessThanOrEqual(4096 * 4096);
+ expect(box.backing[0] / box.width).toBeCloseTo(box.backing[1] / box.height, 3);
+ continue;
+ }
expect([box.width, box.height], `zoom ${zoom}`).toEqual(box.backing);
// What the screen shows matches the backing store exactly.
const clip = {x: Math.ceil(box.left) + 90, y: Math.max(0, Math.ceil(box.top)) + 90, width: 300, height: 120};
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/browser/chart-diagram-keyboard.spec.js b/src/documents/tests/browser/chart-diagram-keyboard.spec.js
index 737002f42..7e461e651 100644
--- a/src/documents/tests/browser/chart-diagram-keyboard.spec.js
+++ b/src/documents/tests/browser/chart-diagram-keyboard.spec.js
@@ -38,6 +38,46 @@ 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([]);
});
+
+test('the Diagram dialog names the items a layout does not show', 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.getByRole('textbox', { name: 'Document text' }).focus();
+ await onTab(page, 'insert');
+ await page.locator('#ribbon-panel-insert').getByRole('button', { name: 'Diagram', exact: true }).click();
+ await page.getByRole('menuitem', { name: /^Basic Cycle/ }).click();
+ const diagram = page.getByRole('dialog', { name: 'Diagram' });
+ await diagram.getByRole('textbox', { name: 'Diagram text' }).fill('Plan\n Budget\nDo\nCheck');
+ const notice = diagram.locator('.insert-diagram-omitted');
+ await expect(notice).toBeVisible();
+ await expect(notice).toHaveText('Basic Cycle does not show this item: “Budget”. It stays in the diagram\'s text; choose another layout to show it.');
+ // A layout that shows details beside each item: nothing to say.
+ await diagram.getByRole('listbox', { name: 'Layouts' }).getByRole('option', { name: /^Vertical List/ }).click();
+ await expect(notice).toBeHidden();
+ // Three levels: the third is named.
+ await diagram.getByRole('textbox', { name: 'Diagram text' }).fill('Plan\n Budget\n Q1\nDo');
+ await expect(notice).toContainText('Vertical List does not show this item: “Q1”');
+ if (process.env.DIAGRAM_SHOTS) await page.screenshot({ path: `${process.env.DIAGRAM_SHOTS}/diagram-omitted.png` });
+});
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([]);
+});
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..43250de96
--- /dev/null
+++ b/src/documents/tests/browser/cjk-justification.spec.js
@@ -0,0 +1,56 @@
+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);
+ }
+});
+
+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([]);
+});
diff --git a/src/documents/tests/browser/clear-formatting.spec.js b/src/documents/tests/browser/clear-formatting.spec.js
index 64b6db18f..2d7820b91 100644
--- a/src/documents/tests/browser/clear-formatting.spec.js
+++ b/src/documents/tests/browser/clear-formatting.spec.js
@@ -69,9 +69,19 @@ test('retains redo for style-only selections and permits clearing invisible dire
await select(input, 'Language only'); await expect(size(page)).toHaveValue('12'); await reset(page).click(); await expect(size(page)).toHaveValue('12'); await expect(undo(page)).toBeEnabled(); await reset(page).click(); await undo(page).click(); await expect(undo(page)).toBeDisabled();
});
-test('rolls back a reset that makes a repeated table header too tall while preserving selection and history', async ({ page }) => {
+test('applies a reset that makes a repeated table header too tall to repeat, preserving selection and history', async ({ page }) => {
+ const errors = []; page.on('pageerror', e => errors.push(e.message));
const input = await fixture(page), text = await input.inputValue(); await select(input, 'Reset header label'); await style(page).selectOption('ResetHuge'); await expect(size(page)).toHaveValue('8');
const pages = await page.locator('canvas.canvas-page').count(), selected = await input.evaluate(el => [el.selectionStart, el.selectionEnd]);
- await reset(page).click(); await expect(page.locator('.toast').last()).toContainText(/header|fit|height/i); await expect(input).toHaveValue(text); await expect(size(page)).toHaveValue('8'); await expect(style(page)).toHaveValue('ResetHuge'); expect(await input.evaluate(el => [el.selectionStart, el.selectionEnd])).toEqual(selected); expect(await page.locator('canvas.canvas-page').count()).toBe(pages);
+ // The header row no longer leaves its page room for the table body: it is laid out once, as an
+ // ordinary row across pages, instead of the reset being refused.
+ await reset(page).click(); await expect(size(page)).toHaveValue('200'); await expect(style(page)).toHaveValue('ResetHuge');
+ await expect(page.locator('.toast')).toHaveCount(0); await expect(input).toHaveValue(text);
+ expect(await input.evaluate(el => [el.selectionStart, el.selectionEnd])).toEqual(selected);
+ await expect.poll(() => page.locator('canvas.canvas-page').count()).toBeGreaterThan(pages);
+ // One undo restores the direct size and the pagination, keeping the selection; the next removes the style.
+ await undo(page).click(); await expect(size(page)).toHaveValue('8'); await expect(style(page)).toHaveValue('ResetHuge');
+ await expect.poll(() => page.locator('canvas.canvas-page').count()).toBe(pages); expect(await input.evaluate(el => [el.selectionStart, el.selectionEnd])).toEqual(selected);
await undo(page).click(); await expect(style(page)).toHaveValue(''); await expect(undo(page)).toBeDisabled();
+ expect(errors).toEqual([]);
});
diff --git a/src/documents/tests/browser/comment-cards-views.spec.js b/src/documents/tests/browser/comment-cards-views.spec.js
new file mode 100644
index 000000000..28d49e06b
--- /dev/null
+++ b/src/documents/tests/browser/comment-cards-views.spec.js
@@ -0,0 +1,68 @@
+import { test, expect } from '@playwright/test';
+import { menuBarMenu } from './helpers/editor-tools.js';
+
+const engine = 'window.__docs.current.view.engine';
+/** A three-page document with a comment near the top of its first page. */
+async function commented(page, width, height) {
+ await page.setViewportSize({ width, height });
+ await page.goto('/mock.html#/new');
+ await expect(page.getByRole('button', { name: 'Bold', exact: true })).toBeEnabled();
+ await page.getByRole('textbox', { name: 'Document text' }).fill(Array.from({ length: 70 }, (_, i) => `Paragraph ${i} has some words that wrap around the line because it is fairly long text.`).join('\n'));
+ await page.evaluate(engine => { const e = eval(engine); e.model.addComment({ author: 'Ada', text: 'A note on the first page', range: [10, 18] }); e.model.select(0); e.relayout(); e.onChange(); }, engine);
+ await expect(page.locator('.ed-comment-card')).toHaveCount(1);
+}
+/** The card's box, the visible part of the pages' scroller, and the pages it covers. */
+const geometry = page => page.evaluate(() => {
+ const card = document.querySelector('.ed-comment-card').getBoundingClientRect(), host = document.querySelector('.canvas-host'), view = host.getBoundingClientRect();
+ const covered = [...document.querySelectorAll('.canvas-pages > canvas')].map(c => c.getBoundingClientRect()).filter(r => r.left < card.right && card.left < r.right && r.top < card.bottom && card.top < r.bottom).length;
+ return { card: [card.left, card.right], view: [view.left, view.left + host.clientWidth], covered, overflow: host.scrollWidth - host.clientWidth };
+});
+async function expectBeside(page) {
+ await expect.poll(async () => { const g = await geometry(page); return g.card[0] >= g.view[0] && g.card[1] <= g.view[1] && g.covered === 0; }, { message: JSON.stringify(await geometry(page)) }).toBe(true);
+}
+
+for (const [width, height, columns] of [[1280, 800, 1], [2000, 1000, 2]]) {
+ test(`in Read Mode with ${columns} reading column${columns > 1 ? 's' : ''} a comment card sits beside the pages, in view`, async ({ page }) => {
+ const errors = []; page.on('pageerror', e => errors.push(e.message));
+ await commented(page, width, height);
+ await page.getByRole('button', { name: 'Read Mode', exact: true }).last().click();
+ await expect(page.locator('.read-counter')).toHaveText(columns > 1 ? /^Pages 1–2 of/ : /^Page 1 of/);
+ await expectBeside(page);
+ expect(errors).toEqual([]);
+ });
+}
+
+test('in Side to Side a comment card sits beside its page without covering the next one', async ({ page }) => {
+ const errors = []; page.on('pageerror', e => errors.push(e.message));
+ await commented(page, 1280, 800);
+ await (await menuBarMenu(page, 'View')).getByRole('menuitem', { name: 'Page Movement' }).click();
+ await page.getByRole('menuitemradio', { name: 'Side to Side' }).click();
+ await expect(page.locator('.canvas-host')).toHaveClass(/is-side-to-side/);
+ await expectBeside(page);
+ expect(errors).toEqual([]);
+});
+
+test('in Web Layout the text leaves room for the comment cards', async ({ page }) => {
+ const errors = []; page.on('pageerror', e => errors.push(e.message));
+ await commented(page, 1280, 800);
+ await page.getByRole('button', { name: 'Web Layout', exact: true }).last().click();
+ await expect(page.locator('.canvas-host')).toHaveClass(/view-web/);
+ await expectBeside(page);
+ expect((await geometry(page)).overflow).toBeLessThanOrEqual(0);
+ expect(errors).toEqual([]);
+});
+
+for (const [width, pane] of [[1100, false], [1440, true]]) {
+ test(`at ${width} pixels${pane ? ' with a side pane open' : ''} the comment column fits the window`, async ({ page }) => {
+ const errors = []; page.on('pageerror', e => errors.push(e.message));
+ await commented(page, width, 800);
+ if (pane) {
+ await page.getByRole('tab', { name: 'Review' }).click();
+ await page.locator('#ribbon-panel-review').getByRole('button', { name: 'Restrict Editing', exact: true }).click();
+ await expect(page.getByRole('complementary', { name: 'Restrict Editing pane' })).toBeVisible();
+ }
+ await expectBeside(page);
+ expect((await geometry(page)).overflow).toBeLessThanOrEqual(0);
+ expect(errors).toEqual([]);
+ });
+}
diff --git a/src/documents/tests/browser/dark-contrast.spec.js b/src/documents/tests/browser/dark-contrast.spec.js
index 36c3683cb..d45cf24b8 100644
--- a/src/documents/tests/browser/dark-contrast.spec.js
+++ b/src/documents/tests/browser/dark-contrast.spec.js
@@ -106,3 +106,26 @@ test('the light theme shows a style sample in the style’s own color', async ({
const pane = await stylesPane(page);
await expect(pane.locator('.styles-pane-name', { hasText: 'Subtitle' })).toHaveCSS('color', 'rgb(89, 89, 89)');
});
+
+for (const colorScheme of ['light', 'dark']) {
+ test(`Read Mode's page counter stays readable on every page color in the ${colorScheme} theme`, async ({ page }) => {
+ await page.emulateMedia({ colorScheme });
+ await page.setViewportSize({ width: 1280, height: 720 });
+ await open(page);
+ await page.getByRole('textbox', { name: 'Document text' }).fill('Words to read. '.repeat(400));
+ await page.getByRole('group', { name: 'Views' }).getByRole('button', { name: 'Read Mode' }).click();
+ const counter = page.locator('.read-counter');
+ await expect(counter).toContainText('of');
+ const viewMenu = async (item, choice) => {
+ await page.getByRole('toolbar', { name: 'Read Mode' }).getByRole('button', { name: 'View', exact: true }).click();
+ await page.getByRole('menuitem', { name: item, exact: true }).click();
+ await page.getByRole('menuitemradio', { name: choice, exact: true }).click();
+ };
+ const check = async (state) => { for (const item of await measure(page, '.read-counter')) expect(item.ratio, `${state}: ${item.color}`).toBeGreaterThanOrEqual(4.5); };
+ await check('None');
+ for (const color of ['Sepia', 'Inverse']) { await viewMenu('Page Color', color); await check(color); }
+ await viewMenu('Page Color', 'None');
+ await viewMenu('Line Focus', 'One Line');
+ await check('Line Focus');
+ });
+}
diff --git a/src/documents/tests/browser/dark-page.spec.js b/src/documents/tests/browser/dark-page.spec.js
new file mode 100644
index 000000000..adbc31497
--- /dev/null
+++ b/src/documents/tests/browser/dark-page.spec.js
@@ -0,0 +1,78 @@
+import { test, expect } from '@playwright/test';
+
+// The page canvas's pixels: the paper in the top margin, and how much of the page is light ink.
+const pagePixels = (page, pick = 'screen') => page.evaluate(pick => {
+ const engine = window.__docs.current.view.engine;
+ const canvas = pick === 'print' ? engine.printSheets([0])[0] : engine.canvases[0];
+ const ctx = canvas.getContext('2d'), { width, height } = canvas;
+ const lum = (d, i) => (0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]) / 255;
+ const paper = ctx.getImageData(Math.round(width * 0.05), Math.round(height * 0.03), 1, 1).data;
+ const all = ctx.getImageData(0, 0, width, Math.round(height / 3)).data;
+ let light = 0, dark = 0;
+ for (let i = 0; i < all.length; i += 4) { const l = lum(all, i); if (l > 0.7) light++; else if (l < 0.15) dark++; }
+ return { paper: lum(paper, 0), light, dark };
+}, pick);
+
+test.describe('dark page', () => {
+ test.use({ colorScheme: 'dark', viewport: { width: 1280, height: 800 } });
+
+ test('Dark page draws dark paper with light ink on screen, and leaves printing white', async ({ page }) => {
+ await page.addInitScript(() => { try { localStorage.setItem('documents:dark-page', '1'); } catch { /* storage unavailable */ } });
+ await page.goto('/mock.html#/new');
+ await expect(page.getByRole('button', { name: 'Bold', exact: true })).toBeEnabled();
+ await page.getByRole('textbox', { name: 'Document text' }).fill('Ink on dark paper. '.repeat(30));
+ await expect.poll(async () => (await pagePixels(page)).paper).toBeLessThan(0.15);
+ const screen = await pagePixels(page);
+ // Light ink, on mostly dark paper.
+ expect(screen.light).toBeGreaterThan(200);
+ expect(screen.dark).toBeGreaterThan(screen.light);
+ // Printing, PDF and the saved file are untouched: their sheets are white paper with dark ink.
+ const print = await pagePixels(page, 'print');
+ expect(print.paper).toBeGreaterThan(0.95);
+ // Turned off in Preferences, the page is white again at once.
+ await page.getByRole('textbox', { name: 'Document text' }).press('ControlOrMeta+,');
+ const dialog = page.getByRole('dialog', { name: 'Preferences' });
+ const toggle = dialog.getByRole('switch', { name: /Dark page/ });
+ await expect(toggle).toBeChecked();
+ await dialog.locator('.pref-row', { hasText: 'Dark page' }).click();
+ await expect(toggle).not.toBeChecked();
+ await expect.poll(async () => (await pagePixels(page)).paper).toBeGreaterThan(0.95);
+ });
+
+ test('the light appearance keeps white paper whatever the Dark page setting', async ({ page }) => {
+ await page.emulateMedia({ colorScheme: 'light' });
+ await page.addInitScript(() => { try { localStorage.setItem('documents:dark-page', '1'); } catch { /* storage unavailable */ } });
+ await page.goto('/mock.html#/new');
+ await expect(page.getByRole('button', { name: 'Bold', exact: true })).toBeEnabled();
+ await page.getByRole('textbox', { name: 'Document text' }).fill('Ink on white paper.');
+ await expect.poll(async () => (await pagePixels(page)).paper).toBeGreaterThan(0.95);
+ });
+
+ test('a dark page context turns colors but not pictures, and reads colors back as they were set', async ({ page }) => {
+ await page.goto('/playground.html');
+ const result = await page.evaluate(async () => {
+ const { setDarkPage } = await import('/src/engine/canvas-dark-page.js');
+ const canvas = Object.assign(document.createElement('canvas'), { width: 4, height: 1 }), ctx = canvas.getContext('2d');
+ const picture = Object.assign(document.createElement('canvas'), { width: 1, height: 1 }), pctx = picture.getContext('2d');
+ pctx.fillStyle = '#ff0000'; pctx.fillRect(0, 0, 1, 1);
+ setDarkPage(ctx, true);
+ ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, 1, 1);
+ const readBack = ctx.fillStyle;
+ ctx.save(); ctx.fillStyle = '#000000'; ctx.fillRect(1, 0, 1, 1); ctx.restore();
+ const restored = ctx.fillStyle;
+ ctx.drawImage(picture, 2, 0);
+ // The insertion caret's blue.
+ ctx.fillStyle = '#375f91'; ctx.fillRect(3, 0, 1, 1);
+ const px = x => [...ctx.getImageData(x, 0, 1, 1).data.slice(0, 3)];
+ return { paper: px(0), ink: px(1), picture: px(2), caret: px(3), readBack, restored };
+ });
+ expect(result.paper).toEqual([28, 28, 28]);
+ expect(result.ink).toEqual([227, 227, 227]);
+ expect(result.picture).toEqual([255, 0, 0]);
+ expect(result.readBack).toBe('#ffffff');
+ expect(result.restored).toBe('#ffffff');
+ // The caret stays a light blue that stands out on the dark paper.
+ expect(result.caret[2]).toBeGreaterThan(result.caret[0]);
+ expect(Math.min(...result.caret)).toBeGreaterThan(110);
+ });
+});
diff --git a/src/documents/tests/browser/design-themes.spec.js b/src/documents/tests/browser/design-themes.spec.js
index bebed8218..08102947d 100644
--- a/src/documents/tests/browser/design-themes.spec.js
+++ b/src/documents/tests/browser/design-themes.spec.js
@@ -231,3 +231,47 @@ test('theme files import, custom paragraph spacing applies, and Set as Default s
await page.evaluate(() => localStorage.removeItem('documents.design.defaults'));
expect(errors).toEqual([]);
});
+
+test('a Word watermark shows as the current watermark, and a new one or Remove Watermark replaces it', async ({ page }, info) => {
+ const errors = []; page.on('pageerror', e => errors.push(e.message));
+ const FIXTURE = 'tests/fixtures/vml-watermarks/watermarks.docx';
+ const word = parts => ['word/header1.xml', 'word/header2.xml'].map(path => (strFromU8(parts[path]).match(/PowerPlusWaterMarkObject|WordPictureWatermark/g) || []).length);
+ const ours = parts => ['word/header1.xml', 'word/header2.xml'].map(path => (strFromU8(parts[path]).match(/name="Document Watermark"/g) || []).length);
+ const openFixture = async () => {
+ await page.goto('/playground.html');
+ await page.getByRole('button', { name: 'New', exact: true }).click();
+ page.once('dialog', dialog => dialog.accept());
+ await page.getByLabel('Open DOCX file').setInputFiles(FIXTURE);
+ await expect(page.getByRole('textbox', { name: 'Document text' })).toHaveValue('Body text.');
+ await onTab(page, 'design');
+ };
+ await openFixture();
+ // The gallery and the dialog show Word's CONFIDENTIAL, diagonal, in Calibri.
+ await ribbon(page).getByRole('button', { name: 'Watermark' }).click();
+ await expect(page.getByRole('menuitemradio', { name: 'CONFIDENTIAL, diagonal' })).toHaveAttribute('aria-checked', 'true');
+ await expect(page.getByRole('menuitem', { name: 'Remove Watermark' })).toBeEnabled();
+ await page.getByRole('menuitem', { name: 'Custom Watermark…' }).click();
+ const dialog = page.getByRole('dialog', { name: 'Watermark' });
+ await expect(dialog.getByLabel('Text')).toHaveValue('CONFIDENTIAL');
+ await expect(dialog.getByRole('combobox', { name: 'Font' })).toHaveValue('Calibri');
+ await expect(dialog.getByRole('radio', { name: 'Diagonal' })).toHaveAttribute('aria-checked', 'true');
+ await dialog.getByRole('button', { name: 'Cancel' }).click();
+ // Untouched, Word's watermark saves as it was.
+ let { parts } = await saved(page, info, 'word-watermark-kept.docx');
+ expect(word(parts)).toEqual([1, 1]);
+ // A new watermark replaces Word's.
+ await ribbon(page).getByRole('button', { name: 'Watermark' }).click();
+ await page.getByRole('menuitemradio', { name: 'DRAFT, horizontal' }).click();
+ ({ parts } = await saved(page, info, 'word-watermark-replaced.docx'));
+ expect(word(parts)).toEqual([0, 0]);
+ expect(ours(parts)).toEqual([1, 1]);
+ // Remove Watermark removes Word's.
+ await openFixture();
+ await ribbon(page).getByRole('button', { name: 'Watermark' }).click();
+ await page.getByRole('menuitem', { name: 'Remove Watermark' }).click();
+ ({ parts } = await saved(page, info, 'word-watermark-removed.docx'));
+ expect(word(parts)).toEqual([0, 0]);
+ expect(ours(parts)).toEqual([0, 0]);
+ expect(strFromU8(parts['word/header1.xml'])).toContain('Acme internal');
+ expect(errors).toEqual([]);
+});
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/browser/document-views.spec.js b/src/documents/tests/browser/document-views.spec.js
index 4c6b32e1c..23d44217b 100644
--- a/src/documents/tests/browser/document-views.spec.js
+++ b/src/documents/tests/browser/document-views.spec.js
@@ -514,3 +514,61 @@ test('the status bar view buttons and the View and Window menus switch views and
await (await menuBarMenu(page, 'Window')).getByRole('menuitemcheckbox', { name: 'Remove Split' }).click();
await expect(page.locator('.view-split-pane')).toHaveCount(0);
});
+
+test('side-to-side pages stay whole above the page thumbnails', async ({ page }) => {
+ await page.goto('/mock.html#/new');
+ await expect(page.getByRole('button', { name: 'Bold', exact: true })).toBeEnabled();
+ await page.getByRole('textbox', { name: 'Document text' }).fill(('A paragraph that helps fill three pages. '.repeat(20) + '\n').repeat(12));
+ const tab = await viewTab(page);
+ await tab.getByRole('button', { name: 'Side to Side', exact: true }).click();
+ await tab.getByRole('button', { name: 'Thumbnails', exact: true }).click();
+ const strip = page.getByRole('list', { name: 'Page thumbnails' });
+ await expect(strip).toBeVisible();
+ // The host shows the pages without scrolling up or down: none may run under the strip.
+ const overlap = () => page.evaluate(() => {
+ const strip = document.querySelector('.view-thumbnails').getBoundingClientRect();
+ const host = document.querySelector('.canvas-host').getBoundingClientRect();
+ return Math.max(0, ...[...document.querySelectorAll('.canvas-host canvas.canvas-page')].map(c => Math.min(c.getBoundingClientRect().bottom, host.bottom) - strip.top));
+ });
+ await expect.poll(overlap).toBe(0);
+ // The Assistant's button rises above the strip too.
+ await expect(page.getByRole('button', { name: 'Assistant', exact: true }).last()).toBeVisible();
+ expect(await page.evaluate(() => document.querySelector('.as-fab').getBoundingClientRect().bottom <= document.querySelector('.view-thumbnails').getBoundingClientRect().top)).toBe(true);
+ // Without the strip the pages fill the height again.
+ const height = () => page.evaluate(() => document.querySelector('.canvas-host canvas.canvas-page').getBoundingClientRect().height);
+ const withStrip = await height();
+ await tab.getByRole('button', { name: 'Thumbnails', exact: true }).click();
+ await expect(strip).toBeHidden();
+ await expect.poll(height).toBeGreaterThan(withStrip + 40);
+});
+
+test('Demote to Body Text offers Ctrl+Shift+N, which the browser leaves to the page, and it works', async ({ page }) => {
+ await headings(page);
+ await (await viewTab(page)).getByRole('button', { name: 'Outline', exact: true }).click();
+ const button = ribbon(page).getByRole('button', { name: 'Demote to Body Text', exact: true });
+ // Cmd+Shift+N opens a private window in Mac browsers, so the page never sees it; Ctrl+Shift+N is the chord everywhere.
+ await expect(button).toHaveAttribute('title', /\(Ctrl\+Shift\+N\)/);
+ const background = page.locator('.outline-row', { hasText: 'Background' });
+ await background.locator('.outline-text').click();
+ await page.keyboard.press('Control+Shift+N');
+ await expect(page.locator('.outline-row', { hasText: 'Background' })).not.toHaveClass(/is-heading/);
+});
+
+test('Focus shows the page alone, without the rulers, and Escape brings them back', async ({ page }) => {
+ await page.emulateMedia({ colorScheme: 'light' });
+ 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('Words to write without distraction.');
+ const ruler = page.locator('.canvas-ruler-top');
+ await expect(ruler).toBeVisible();
+ await page.getByLabel('Status bar').getByRole('button', { name: 'Focus', exact: true }).click();
+ await expect(page.locator('.ed-workspace')).toHaveClass(/ed-focus/);
+ // The light rulers would stand out on Focus's dark surround: they go with the rest of the window.
+ await expect(ruler).toBeHidden();
+ await expect(page.locator('.canvas-ruler-side')).toBeHidden();
+ await expect(page.locator('.canvas-tab-selector')).toBeHidden();
+ await input.press('Escape');
+ await expect(page.locator('.ed-workspace')).not.toHaveClass(/ed-focus/);
+ await expect(ruler).toBeVisible();
+});
diff --git a/src/documents/tests/browser/draw-overlay-limit.spec.js b/src/documents/tests/browser/draw-overlay-limit.spec.js
new file mode 100644
index 000000000..ce8437b11
--- /dev/null
+++ b/src/documents/tests/browser/draw-overlay-limit.spec.js
@@ -0,0 +1,40 @@
+import { test, expect } from '@playwright/test';
+import { onTab } from './helpers/editor-tools.js';
+
+// The Draw tab paints strokes being drawn on a canvas over the page. Like the page canvas, it must stay
+// within Safari's 4096 × 4096 pixel limit (a Letter page at 200% on a 3x display would need
+// 4896 × 6336) or it draws nothing; with fewer device pixels the ink must still land under the pointer.
+test.use({ viewport: { width: 1800, height: 900 }, deviceScaleFactor: 3 });
+test('ink being drawn on a zoomed page stays within the canvas pixel limit and under the pointer', async ({ page }) => {
+ await page.goto('/playground.html');
+ const input = page.getByRole('textbox', { name: 'Document text' });
+ await expect(input).toHaveValue(/^A small beginning/);
+ await page.getByRole('combobox', { name: 'Zoom', exact: true }).selectOption('2');
+ const canvas = page.locator('canvas.canvas-page').first();
+ await expect.poll(() => canvas.evaluate(c => c.getBoundingClientRect().width)).toBeCloseTo(1632, 0);
+ await onTab(page, 'draw');
+ await page.locator('#ribbon-panel-draw .draw-pen').first().click();
+ const box = await canvas.boundingBox();
+ // A horizontal stroke below the text, checked while it is being drawn (the overlay shows it until the pointer lifts).
+ const y = Math.min(box.y + 560, 860), from = { x: box.x + 300, y }, to = { x: box.x + 600, y };
+ await page.mouse.move(from.x, from.y); await page.mouse.down();
+ for (let i = 1; i <= 10; i++) await page.mouse.move(from.x + (to.x - from.x) * i / 10, from.y, { steps: 2 });
+ const overlay = page.locator('canvas.draw-overlay').first();
+ await expect(overlay).toHaveCount(1);
+ const size = await overlay.evaluate(c => ({ width: c.width, height: c.height }));
+ expect(size.width * size.height).toBeLessThanOrEqual(4096 * 4096);
+ // The ink's painted extent, in CSS pixels from the overlay's corner, or null until painted.
+ const ink = () => overlay.evaluate(c => {
+ const data = c.getContext('2d').getImageData(0, 0, c.width, c.height).data, scale = c.getBoundingClientRect().width / c.width;
+ let left = Infinity, right = -1, top = Infinity, bottom = -1;
+ for (let y = 0; y < c.height; y += 2) for (let x = 0; x < c.width; x += 2) if (data[(y * c.width + x) * 4 + 3] > 128) { left = Math.min(left, x); right = Math.max(right, x); top = Math.min(top, y); bottom = Math.max(bottom, y); }
+ return right < 0 ? null : { left: left * scale, right: right * scale, top: top * scale, bottom: bottom * scale };
+ });
+ await expect.poll(ink).not.toBeNull();
+ const painted = await ink(), origin = await overlay.boundingBox();
+ // Within a few pixels of where the pointer went.
+ expect(Math.abs(painted.left - (from.x - origin.x))).toBeLessThan(12);
+ expect(Math.abs(painted.right - (to.x - origin.x))).toBeLessThan(12);
+ expect(Math.abs((painted.top + painted.bottom) / 2 - (from.y - origin.y))).toBeLessThan(6);
+ await page.mouse.up();
+});
diff --git a/src/documents/tests/browser/draw.spec.js b/src/documents/tests/browser/draw.spec.js
index d78a20478..4daeb8f1f 100644
--- a/src/documents/tests/browser/draw.spec.js
+++ b/src/documents/tests/browser/draw.spec.js
@@ -297,3 +297,68 @@ 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([]);
+});
+
+test('ink drawn on a page where no paragraph starts stays on that page', async ({ page }) => {
+ test.setTimeout(120000);
+ // One long paragraph running onto page 2, then a table that fills pages of its own.
+ const { input, errors, ribbon } = await start(page, { text: 'Words that run on and on across the page. '.repeat(160) });
+ await input.press('ControlOrMeta+End');
+ await input.press('Enter');
+ await onTab(page, 'insert');
+ await page.locator('#ribbon-panel-insert').getByRole('button', { name: 'Insert table', exact: true }).click();
+ await page.getByRole('menuitem', { name: 'Insert Table…' }).click();
+ const dialog = page.getByRole('dialog', { name: 'Insert Table' });
+ await dialog.getByLabel('Number of columns').fill('2'); await dialog.getByLabel('Number of rows').fill('120');
+ await dialog.getByRole('button', { name: 'OK' }).click();
+ await expect.poll(() => page.locator('canvas.canvas-page').count()).toBeGreaterThanOrEqual(4);
+ await onTab(page, 'draw');
+ await ribbon.locator('.draw-pen').first().click();
+ // Pixels that are not white around a point of a page canvas.
+ const marked = (index, x, y) => page.evaluate(([index, x, y]) => {
+ const c = document.querySelectorAll('canvas.canvas-page')[index], ctx = c.getContext('2d'), s = c.width / c.getBoundingClientRect().width;
+ const d = ctx.getImageData(Math.round((x - 60) * s), Math.round((y - 30) * s), Math.round(120 * s), Math.round(60 * s)).data;
+ let n = 0; for (let i = 0; i < d.length; i += 4) if (d[i] < 200 || d[i + 1] < 200 || d[i + 2] < 200) n++; return n;
+ }, [index, x, y]);
+ const inkOn = async index => {
+ const canvas = page.locator('canvas.canvas-page').nth(index);
+ await canvas.scrollIntoViewIfNeeded();
+ const box = await canvas.boundingBox(), before = await objects(input), plain = await marked(index, 375, 420);
+ await draw(page, box, wave(300, 420, 16, 10, 20));
+ await ribbon.getByRole('button', { name: 'Select', exact: true }).click();
+ await expect.poll(() => objects(input)).toBe(before + 1);
+ // The ink shows where it was drawn: on this page, not on the page its paragraph starts on.
+ await canvas.scrollIntoViewIfNeeded();
+ await expect.poll(() => marked(index, 375, 420)).toBeGreaterThan(plain + 100);
+ await ribbon.locator('.draw-pen').first().click();
+ };
+ await inkOn(1); // the long paragraph continues here
+ await inkOn(3); // only table rows here
+ await expect(page.getByText(/Ink needs a paragraph/)).toHaveCount(0);
+ expect(errors).toEqual([]);
+});
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([]);
+});
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([]);
+});
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/browser/field-update-story.spec.js b/src/documents/tests/browser/field-update-story.spec.js
new file mode 100644
index 000000000..aac933ea8
--- /dev/null
+++ b/src/documents/tests/browser/field-update-story.spec.js
@@ -0,0 +1,35 @@
+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] });
+ // Keys typed straight after F9, while the update is still running, go into the footer, in order: Update
+ // Fields does its work in the body without the footer closing in between.
+ await page.keyboard.press('F9');
+ await page.keyboard.insertText('X');
+ await page.keyboard.insertText('Y');
+ await expect.poll(async () => (await state()).story).toBe(`${before.story.slice(0, 2)}XY${before.story.slice(2)}`);
+ // And once the update has finished, they are still there and the body is as it was.
+ await page.waitForFunction(() => window.__docs.current.view.engine.fieldUpdateRunning === false);
+ const after = await state();
+ expect(after).toMatchObject({ path: footer, range: [4, 4], body: before.body });
+ expect(after.story).toBe(`${before.story.slice(0, 2)}XY${before.story.slice(2)}`);
+});
diff --git a/src/documents/tests/browser/find-replace.spec.js b/src/documents/tests/browser/find-replace.spec.js
index acf7ea28a..dc009be20 100644
--- a/src/documents/tests/browser/find-replace.spec.js
+++ b/src/documents/tests/browser/find-replace.spec.js
@@ -70,13 +70,20 @@ test('keeps search results current during document editing, undo and composition
await input.dispatchEvent('keydown', { key: 'f', ctrlKey: true, keyCode: 229 }); await expect(panel(page)).toBeHidden();
});
-test('repaginates replacements in flowing cells and atomically rolls back an oversized repeated header', async ({ page }) => {
+test('repaginates replacements in flowing cells and lays out a header grown too tall to repeat once', async ({ page }) => {
+ const errors = []; page.on('pageerror', e => errors.push(e.message));
const input = await fixture(page), original = await input.inputValue(), pages = await page.locator('canvas.canvas-page').count(); await open(page, input); await query(page).fill('Flowing');
await replacement(page).fill('Many more flowing words that fill the cell'); await panel(page).getByRole('button', { name: 'Replace all', exact: true }).click(); expect(await page.locator('canvas.canvas-page').count()).toBeGreaterThan(pages);
await undo(page).click(); await expect(input).toHaveValue(original); expect(await page.locator('canvas.canvas-page').count()).toBe(pages); await expect(undo(page)).toBeDisabled();
- await query(page).fill('needle header'); await replacement(page).fill('Huge repeated header '.repeat(200)); await panel(page).getByRole('button', { name: 'Replace all', exact: true }).click();
- await expect(panel(page).getByRole('alert')).toContainText(/header|height|fit/i); await expect(input).toHaveValue(original); expect(await page.locator('canvas.canvas-page').count()).toBe(pages); await expect(undo(page)).toBeDisabled(); await expect(redo(page)).toBeEnabled();
+ // A repeated header that no longer leaves its page room for the table body is not repeated: the
+ // replacement is applied, the header row flows once across pages, and one undo restores everything.
+ const huge = 'Huge repeated header '.repeat(200);
+ await query(page).fill('needle header'); await replacement(page).fill(huge); await panel(page).getByRole('button', { name: 'Replace all', exact: true }).click();
+ await expect(input).toHaveValue(original.replace('needle header', huge)); await expect(panel(page).getByRole('alert')).toBeHidden();
+ await expect.poll(() => page.locator('canvas.canvas-page').count()).toBeGreaterThan(pages); await expect(undo(page)).toBeEnabled(); await expect(redo(page)).toBeDisabled();
+ await undo(page).click(); await expect(input).toHaveValue(original); await expect.poll(() => page.locator('canvas.canvas-page').count()).toBe(pages); await expect(undo(page)).toBeDisabled(); await expect(redo(page)).toBeEnabled();
await replacement(page).fill('Small header'); await panel(page).getByRole('button', { name: 'Replace all', exact: true }).click(); await expect(panel(page).getByRole('alert')).toBeHidden(); await expect(input).toHaveValue(original.replace('needle header', 'Small header'));
+ expect(errors).toEqual([]);
});
test('keeps search controls reachable on a narrow screen and preserves history for a no-op replacement', async ({ page }, info) => {
@@ -179,3 +186,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');
+});
diff --git a/src/documents/tests/browser/format-paste-find.spec.js b/src/documents/tests/browser/format-paste-find.spec.js
index 881f9c361..b03d6a3b8 100644
--- a/src/documents/tests/browser/format-paste-find.spec.js
+++ b/src/documents/tests/browser/format-paste-find.spec.js
@@ -148,9 +148,11 @@ test('Advanced Find searches headers and footers and a selection', async ({ page
await panel.getByRole('textbox', { name: 'Replace with', exact: true }).fill('summary');
await panel.getByRole('button', { name: 'Replace all', exact: true }).click();
await expect(panel.locator('.canvas-find-notice')).toHaveText('Replaced 1 match.');
- // A selection scope counts only matches inside the selection.
+ // The main document is the body, also from the header just edited: its three matches, one of them chosen in the body.
await panel.getByLabel('Search in').selectOption('document');
- await page.getByRole('button', { name: 'Close Header and Footer' }).click().catch(() => {});
+ await expect(panel.locator('.canvas-find-count')).toHaveText(/of 3$/);
+ await expect(page.getByRole('button', { name: 'Close Header and Footer' })).toHaveCount(0);
+ // A selection scope counts only matches inside the selection.
await input.evaluate(el => { el.setSelectionRange(0, 15); el.dispatchEvent(new Event('select', { bubbles: true })); });
await panel.getByLabel('Search in').selectOption('selection');
await expect(panel.locator('.canvas-find-count')).toHaveText(/1 match|1 of 1/);
diff --git a/src/documents/tests/browser/formatted-clipboard.spec.js b/src/documents/tests/browser/formatted-clipboard.spec.js
index 32e7655c7..780bcce5f 100644
--- a/src/documents/tests/browser/formatted-clipboard.spec.js
+++ b/src/documents/tests/browser/formatted-clipboard.spec.js
@@ -162,3 +162,59 @@ 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);
+ // Off a Mac, Chrome's own key binding for Ctrl+Shift+V pastes and matches style; on a Mac the command is sent as a
+ // browser that binds Cmd+Shift+V would. Sending it off a Mac as well would paste twice.
+ const mac = process.platform === 'darwin', modifiers = mac ? 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' && mac ? { 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([]);
+});
+
+for (const platform of ['MacIntel', 'Linux x86_64']) test(`Paste without formatting pastes once when the browser's paste event comes late (${platform})`, async ({ browser }) => {
+ // The chord's keydown, then the browser's own paste arriving well after it (as on a busy Linux machine), with the
+ // clipboard as the document would read it: the text comes in once, without formatting, whichever way it arrives.
+ const context = await browser.newContext();
+ const page = await context.newPage();
+ await page.addInitScript(value => Object.defineProperty(Navigator.prototype, 'platform', { get: () => value }), platform);
+ try {
+ await blank(page);
+ const errors = []; page.on('pageerror', e => errors.push(e.message));
+ await input(page).focus();
+ await page.keyboard.type('Plain ');
+ await page.evaluate(() => { navigator.clipboard.readText = async () => 'Bold'; });
+ await input(page).evaluate(el => {
+ const mac = /Mac/.test(navigator.platform);
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: 'V', code: 'KeyV', shiftKey: true, ctrlKey: !mac, metaKey: mac, bubbles: true, cancelable: true }));
+ });
+ await page.waitForTimeout(100);
+ await input(page).evaluate(el => {
+ const data = new DataTransfer(); data.setData('text/plain', 'Bold'); data.setData('text/html', 'Bold');
+ el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }));
+ });
+ await page.waitForTimeout(100);
+ await expect(input(page)).toHaveValue('Plain Bold');
+ expect(await state(page, 'Bold', 6)).toMatchObject({ bold: 'false' });
+ expect(errors).toEqual([]);
+ } finally { await context.close(); }
+});
diff --git a/src/documents/tests/browser/go-to-page-numbers.spec.js b/src/documents/tests/browser/go-to-page-numbers.spec.js
new file mode 100644
index 000000000..888f90e3c
--- /dev/null
+++ b/src/documents/tests/browser/go-to-page-numbers.spec.js
@@ -0,0 +1,42 @@
+import { test, expect } from '@playwright/test';
+
+// The offset fixture's sections restart and reformat their page numbers: its fifth sheet is numbered 8,
+// its eleventh L, and 1 appears twice (sheets 8 and 26), the second time in a later section.
+const FIXTURE = 'public/fixtures/section-page-numbers-offset.docx';
+const sheet = page => page.evaluate(() => window.__docs.current.view.engine.caretPageIndex);
+
+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(); });
+}
+async function goTo(page, value) {
+ await page.getByRole('textbox', { name: 'Document text' }).press(process.platform === 'darwin' ? 'Meta+Alt+g' : 'Control+g');
+ const dialog = page.getByRole('dialog', { name: 'Go To' });
+ await dialog.getByRole('textbox', { name: 'Enter page number' }).fill(value);
+ await dialog.getByRole('textbox', { name: 'Enter page number' }).press('Enter');
+ await expect(dialog).toBeHidden();
+}
+
+test('Go To Page finds the page by the number printed on it, in a section that restarts numbering', async ({ page }) => {
+ await open(page);
+ await goTo(page, '8');
+ expect(await sheet(page)).toBe(4);
+ // A number shown in letters or Roman numerals is found as it is shown.
+ await goTo(page, 'L');
+ expect(await sheet(page)).toBe(10);
+ // A number used again in a later section: the first, or the one in the section named with "s".
+ await goTo(page, '1');
+ expect(await sheet(page)).toBe(7);
+ const section = await page.evaluate(() => window.__docs.current.view.engine.layout.pageSections[25] + 1);
+ await goTo(page, `p1s${section}`);
+ expect(await sheet(page)).toBe(25);
+ // + and − still count pages from where the caret is.
+ await goTo(page, '8');
+ await goTo(page, '+2');
+ expect(await sheet(page)).toBe(6);
+});
diff --git a/src/documents/tests/browser/google-docs-import.spec.js b/src/documents/tests/browser/google-docs-import.spec.js
new file mode 100644
index 000000000..f14fb9c0b
--- /dev/null
+++ b/src/documents/tests/browser/google-docs-import.spec.js
@@ -0,0 +1,25 @@
+import { test, expect } from '@playwright/test';
+
+// A web page from Google Docs' File > Download > Web page: a blank line is an empty paragraph the style sheet gives a
+// line's height, and nested lists are lists of their own, nested by class (lst-kix_-).
+const PAGE = `
+Plan
+After a blank line
+- One
+
+- Two
+
End
`;
+
+test('a Google Docs web page opens with its blank lines and nested, numbered lists', async ({ page }) => {
+ 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: 'Plan.html', mimeType: 'text/html', buffer: Buffer.from(PAGE) });
+ const input = page.getByRole('textbox', { name: 'Document text' });
+ await expect(input).toHaveValue('Plan\n\nAfter a blank line\nOne\nDetail\nTwo\n\nEnd');
+ const lists = await page.evaluate(() => window.__docs.current.view.engine.model.paragraphs.map(p => p.list?.level ?? null));
+ expect(lists).toEqual([null, null, null, 0, 1, 0, null, null]);
+ await expect(page.locator('.editor-error')).toHaveCount(0);
+});
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..20b7cd385
--- /dev/null
+++ b/src/documents/tests/browser/group-stacking.spec.js
@@ -0,0 +1,95 @@
+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([]);
+});
+
+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([]);
+});
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..d874e2541
--- /dev/null
+++ b/src/documents/tests/browser/header-footer-variant.spec.js
@@ -0,0 +1,68 @@
+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);
+});
+
+// Header from Top applies to the section whose header is being edited, as Page Setup's margins do, and shows its value.
+test('Header from Top changes the distance of the section being edited', 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 sections = () => page.evaluate(() => { const e = window.__docs.current.view.engine; return { footers: e.session.body.sectionSettings.map(s => s.footer), path: e.session.path, range: [...e.model.range] }; });
+ const before = await sections();
+ expect(before.footers.length).toBeGreaterThan(2);
+ const path = await page.evaluate(() => {
+ const e = window.__docs.current.view.engine;
+ const p = e.storyLayout.lines.find(line => line.page === 0 && e.pageStories.stories[line.pageStory]?.kind === 'footer').pageStory;
+ e.editStory(p, 0); e.model.select(2); e.focus(); return p;
+ });
+ const distance = page.getByRole('spinbutton', { name: 'Footer from Bottom (in)' });
+ await expect(distance).toHaveValue(String(Math.round(before.footers[0] / 1440 * 100) / 100));
+ await distance.fill('1.25'); await distance.press('Enter');
+ await expect.poll(async () => (await sections()).footers).toEqual([1800, ...before.footers.slice(1)]);
+ // Still in the footer, caret where it was; one Undo restores the distance.
+ expect(await sections()).toMatchObject({ path, range: [2, 2] });
+ await page.keyboard.press('ControlOrMeta+z');
+ await expect.poll(async () => (await sections()).footers).toEqual(before.footers);
+});
diff --git a/src/documents/tests/browser/hidden-text-caret.spec.js b/src/documents/tests/browser/hidden-text-caret.spec.js
new file mode 100644
index 000000000..8c6636e06
--- /dev/null
+++ b/src/documents/tests/browser/hidden-text-caret.spec.js
@@ -0,0 +1,60 @@
+import { test, expect } from '@playwright/test';
+import { menuBarMenu } from './helpers/editor-tools.js';
+
+const input = page => page.getByRole('textbox', { name: 'Document text' });
+const runs = page => page.evaluate(() => window.__docs.current.view.engine.model.paragraphs[0].runs.map(r => [r.text, r.revision?.type || '']));
+const caret = page => input(page).evaluate(el => [el.selectionStart, el.selectionEnd]);
+async function markup(page, label) {
+ await (await menuBarMenu(page, 'View')).getByRole('menuitem', { name: 'Markup', exact: true }).click();
+ await page.getByRole('menuitemradio', { name: label, exact: true }).click();
+ await input(page).focus();
+}
+
+test('with deletions hidden, arrows, Backspace, Delete and typing pass over them', async ({ page }) => {
+ await page.goto('/mock.html#/new');
+ await expect(page.getByRole('button', { name: 'Bold', exact: true })).toBeEnabled();
+ await input(page).fill('Alpha beta gamma.');
+ await input(page).press('ControlOrMeta+Shift+e');
+ await input(page).evaluate(el => { el.setSelectionRange(6, 11); el.dispatchEvent(new Event('select')); });
+ await input(page).press('Backspace');
+ expect(await runs(page)).toEqual([['Alpha ', ''], ['beta ', 'del'], ['gamma.', '']]);
+ for (const view of ['No Markup', 'Simple Markup']) {
+ await markup(page, view);
+ // Before "gamma": one Left goes before the space that shows before it.
+ await input(page).evaluate(el => { el.setSelectionRange(11, 11); el.dispatchEvent(new Event('select')); });
+ await input(page).press('ArrowLeft');
+ expect(await caret(page)).toEqual([5, 5]);
+ await input(page).press('ArrowRight');
+ expect(await caret(page)).toEqual([6, 6]);
+ await input(page).press('ArrowRight');
+ expect(await caret(page)).toEqual([12, 12]);
+ await input(page).press('Shift+ArrowLeft'); await input(page).press('Shift+ArrowLeft');
+ expect(await caret(page)).toEqual([5, 12]);
+ await input(page).press('ArrowRight');
+ }
+ // The reported case: Backspace deletes the space a reader sees, and typing lands in shown text.
+ await input(page).evaluate(el => { el.setSelectionRange(11, 11); el.dispatchEvent(new Event('select')); });
+ await input(page).press('Backspace');
+ await input(page).press('ArrowLeft'); await input(page).press('ArrowLeft');
+ await page.keyboard.type('Z');
+ await markup(page, 'All Markup');
+ expect(await runs(page)).toEqual([['Alp', ''], ['Z', 'ins'], ['ha', ''], [' beta ', 'del'], ['gamma.', '']]);
+ // Delete before hidden text deletes the character after it.
+ await markup(page, 'No Markup');
+ await input(page).evaluate(el => { el.setSelectionRange(6, 6); el.dispatchEvent(new Event('select')); });
+ await input(page).press('Delete');
+ await markup(page, 'All Markup');
+ expect(await runs(page)).toEqual([['Alp', ''], ['Z', 'ins'], ['ha', ''], [' beta ', 'del'], ['g', 'del'], ['amma.', '']]);
+});
+
+test('hidden text is passed over while it is not shown', async ({ page }) => {
+ await page.goto('/playground.html');
+ await page.getByLabel('Open DOCX file').setInputFiles('public/fixtures/format-features.docx');
+ await expect(input(page)).toHaveValue(/Visible hidden words end\./);
+ const at = await input(page).evaluate(el => el.value.indexOf('Visible hidden words end.'));
+ await input(page).evaluate((el, at) => { el.focus(); el.setSelectionRange(at + 20, at + 20); el.dispatchEvent(new Event('select')); }, at);
+ await input(page).press('ArrowLeft');
+ expect(await caret(page)).toEqual([at + 7, at + 7]);
+ await input(page).press('ArrowRight'); await input(page).press('ArrowRight');
+ expect(await caret(page)).toEqual([at + 21, at + 21]);
+});
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"');
+});
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..014101592
--- /dev/null
+++ b/src/documents/tests/browser/import-embedded-fragment.spec.js
@@ -0,0 +1,43 @@
+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 = `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 }) => {
+ // Wide enough that the Insert tab's Text group shows its buttons with any system font.
+ await page.setViewportSize({ width: 1440, height: 900 });
+ 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%\.$/);
+});
diff --git a/src/documents/tests/browser/justification.spec.js b/src/documents/tests/browser/justification.spec.js
index 97f5e8ce6..1ba48e076 100644
--- a/src/documents/tests/browser/justification.spec.js
+++ b/src/documents/tests/browser/justification.spec.js
@@ -85,12 +85,40 @@ test('uses native shaping for expanded spaces, matches paint widths and keeps zo
test('previews unsupported justification as read-only with the original available', async ({ page }) => {
const input = await fixture(page), text = await input.inputValue();
- for (const [from, to, error] of [['', '', /distribute paragraph alignment/], ['Direct both ', 'Direct both العربية ', /kashida/], ['w:pos="1800" w:val="left"', 'w:pos="1800" w:val="center"', /justification after/]]) {
+ for (const [from, to, error] of [['', '', /thaiDistribute paragraph alignment/]]) {
const parts = unzipSync(readFileSync('public/fixtures/justification.docx')), xml = strFromU8(parts['word/document.xml']); expect(xml).toContain(from); parts['word/document.xml'] = strToU8(xml.replace(from, to));
await openReadOnlyPreview(page, { name: 'unsupported-justification.docx', mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', buffer: Buffer.from(zipSync(parts)) }); await expect(page.getByRole('button', { name: 'Download .docx', exact: true })).toBeEnabled();
}
await fixture(page);
- await choose(input, 'Direct both'); await page.keyboard.insertText('العربية '); await expect(page.getByText(/kashida/)).toBeVisible(); await expect(input).toHaveValue(text); await expect(page.getByRole('button', { name: 'Undo', exact: true })).toBeDisabled();
+});
+
+// A justified line whose last tab is a center, right or decimal stop keeps its natural spacing, and the
+// document opens for editing (before, it opened read-only).
+test('opens justified paragraphs with a center tab for editing', async ({ page }) => {
+ await page.goto('/playground.html'); await showAllTools(page);
+ const input = page.getByRole('textbox', { name: 'Document text' }); await expect(input).toHaveValue(/^A small beginning/);
+ const parts = unzipSync(readFileSync('public/fixtures/justification.docx')), xml = strFromU8(parts['word/document.xml']);
+ expect(xml).toContain('w:pos="1800" w:val="left"'); parts['word/document.xml'] = strToU8(xml.replace('w:pos="1800" w:val="left"', 'w:pos="1800" w:val="center"'));
+ await page.getByLabel('Open DOCX file').setInputFiles({ name: 'center-tab.docx', mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', buffer: Buffer.from(zipSync(parts)) });
+ await expect(input).toHaveValue(/^Justified paragraphs/); await expect(input).toHaveJSProperty('readOnly', false);
+ await expect(page.locator('#playground-error')).toBeEmpty();
+});
+
+// Arabic typed into a justified paragraph is accepted and justified by its word spaces (no kashida).
+test('justifies typed Arabic by its word spaces with native canvas metrics', async ({ page }) => {
+ const input = await fixture(page), text = await input.inputValue();
+ await choose(input, 'Direct both'); await page.keyboard.insertText('العربية '); await expect(input).toHaveValue(text.replace('Direct both', 'العربية Direct both'));
+ await expect(page.getByRole('button', { name: 'Undo', exact: true })).toBeEnabled(); await expect(page.getByText(/kashida/)).toHaveCount(0);
+ const edges = await page.evaluate(async () => {
+ const { measureCanvasText } = await import('/src/engine/canvas-text.js'), { layoutDocument } = await import('/src/engine/layout.js'), { paragraph } = await import('/src/engine/model.js');
+ const ctx = document.createElement('canvas').getContext('2d'), measure = (t, s) => measureCanvasText(ctx, t, s);
+ const arabic = 'هذا نص عربي طويل يلتف عبر عدة أسطر للتأكد من أن التخطيط يعمل بشكل صحيح في المحرر مع English words أيضا';
+ const layout = layoutDocument([paragraph(arabic, { format: { spaceAfter: 0, alignment: 'both' } })], measure, { width: 300, height: 4000, margin: 20 });
+ // Ink extents: the reordered right-to-left text spans the whole measure; the hanging space is clipped at its edge.
+ return layout.lines.map(l => ({ last: l.paragraphEnd, spacing: l.justificationSpacing, left: Math.min(...l.fragments.filter(f => f.text.trim()).map(f => f.x)), right: Math.max(...l.fragments.filter(f => f.text.trim()).map(f => f.x + f.width)) }));
+ });
+ expect(edges.length).toBeGreaterThan(2);
+ for (const line of edges.filter(l => !l.last)) { expect(line.spacing).toBeGreaterThan(0); expect(Math.abs(line.left - 20)).toBeLessThan(0.5); expect(Math.abs(line.right - 280)).toBeLessThan(0.5); }
});
test('previews justified documents when Canvas word spacing is unavailable', async ({ page }) => {
@@ -118,3 +146,24 @@ test('fits justified rich text into fractional-width table cells using native fo
});
expect(failures).toEqual([]);
});
+
+// Distributed alignment (§17.18.44 distribute): every line, the last one included, spans the measure,
+// by equal gaps between all characters, measured and painted with native letter spacing.
+test('opens distributed paragraphs for editing and spreads every line with native canvas metrics', async ({ page }) => {
+ await page.goto('/playground.html'); await showAllTools(page);
+ const input = page.getByRole('textbox', { name: 'Document text' }); await expect(input).toHaveValue(/^A small beginning/);
+ const parts = unzipSync(readFileSync('public/fixtures/justification.docx')), xml = strFromU8(parts['word/document.xml']);
+ parts['word/document.xml'] = strToU8(xml.replaceAll('', ''));
+ await page.getByLabel('Open DOCX file').setInputFiles({ name: 'distributed.docx', mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', buffer: Buffer.from(zipSync(parts)) });
+ await expect(input).toHaveValue(/^Justified paragraphs/); await expect(input).toHaveJSProperty('readOnly', false);
+ await choose(input, 'Direct both'); await page.keyboard.insertText('Typed '); await expect(input).toHaveValue(/Typed Direct both/);
+ const lines = await page.evaluate(async () => {
+ const { measureCanvasText } = await import('/src/engine/canvas-text.js'), { layoutDocument } = await import('/src/engine/layout.js'), { paragraph } = await import('/src/engine/model.js');
+ const ctx = document.createElement('canvas').getContext('2d'), measure = (t, s) => measureCanvasText(ctx, t, s);
+ const layout = layoutDocument(['Distributed title', 'Alpha beta gamma delta epsilon zeta eta theta iota kappa', '分散对齐的中文段落最后一行也要对齐'].map(t => paragraph(t, { format: { spaceAfter: 0, alignment: 'distribute' } })), measure, { width: 300, height: 4000, margin: 20 });
+ // A line of one character has no gap to widen.
+ return layout.lines.filter(l => l.end - l.start > 1).map(l => ({ left: Math.min(...l.stops.map(s => s.x)), right: Math.max(...l.stops.map(s => s.x)) - (l.hangingRight ? 0 : l.ignoredTrailingWidth) }));
+ });
+ expect(lines.length).toBeGreaterThan(3);
+ for (const line of lines) { expect(Math.abs(line.left - 20)).toBeLessThan(0.5); expect(Math.abs(line.right - 280)).toBeLessThan(0.5); }
+});
diff --git a/src/documents/tests/browser/keyboard-shortcuts.spec.js b/src/documents/tests/browser/keyboard-shortcuts.spec.js
index 0f0482a9f..b345f585f 100644
--- a/src/documents/tests/browser/keyboard-shortcuts.spec.js
+++ b/src/documents/tests/browser/keyboard-shortcuts.spec.js
@@ -108,3 +108,48 @@ 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);
+ }
+});
+
+test('a shortcut a menu shows belongs to that command alone and runs it', async ({ page }) => {
+ 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('Some words to count and find. Some more words.');
+ const bar = page.getByRole('menubar', { name: 'Menu bar' });
+ const shortcuts = async () => page.locator('.menu .menu-item').evaluateAll(items => items.map(item => [item.querySelector('.menu-label')?.firstChild?.textContent, item.querySelector('.menu-shortcut')?.textContent]).filter(([, key]) => key));
+ await bar.getByRole('menuitem', { name: 'Edit', exact: true }).click();
+ await page.getByRole('menuitem', { name: 'Find', exact: true }).click();
+ const shown = await shortcuts();
+ await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
+ await bar.getByRole('menuitem', { name: 'Tools', exact: true }).click();
+ shown.push(...await shortcuts());
+ const wordCount = shown.find(([label]) => label === 'Word Count…')?.[1];
+ await page.keyboard.press('Escape');
+ const owners = new Map();
+ for (const [label, key] of shown) owners.set(key, [...(owners.get(key) || []), label]);
+ expect([...owners].filter(([, labels]) => new Set(labels).size > 1)).toEqual([]);
+ if (wordCount) {
+ await input.focus();
+ await page.keyboard.press(wordCount.replace('Cmd', 'Meta').replace('Ctrl', 'Control').replace('Option', 'Alt'));
+ await expect(page.getByRole('dialog', { name: 'Word Count' })).toBeVisible();
+ }
+});
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([]);
+});
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([]);
+});
diff --git a/src/documents/tests/browser/menu-clipboard.spec.js b/src/documents/tests/browser/menu-clipboard.spec.js
index 880f36ed1..36cd5b0fc 100644
--- a/src/documents/tests/browser/menu-clipboard.spec.js
+++ b/src/documents/tests/browser/menu-clipboard.spec.js
@@ -75,3 +75,52 @@ 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');
+ }
+ // 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');
+});
+
+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);
+});
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);
+});
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 });
+});
diff --git a/src/documents/tests/browser/note-settings-apply.spec.js b/src/documents/tests/browser/note-settings-apply.spec.js
new file mode 100644
index 000000000..c4af20289
--- /dev/null
+++ b/src/documents/tests/browser/note-settings-apply.spec.js
@@ -0,0 +1,37 @@
+import { test, expect } from '@playwright/test';
+import { readFileSync } from 'node:fs';
+import { unzipSync, strFromU8 } from 'fflate';
+import { onTab, downloadDocx } from './helpers/editor-tools.js';
+
+// The Footnote and Endnote dialog writes only the settings that were changed: Apply with nothing changed leaves
+// settings.xml alone, and a footnote format change does not add endnote settings.
+test('Footnote and Endnote Apply writes only changed note settings', 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('A claim.');
+ await onTab(page, 'references');
+ const ribbon = page.locator('#ribbon-panel-references');
+ const settings = async name => {
+ const pending = page.waitForEvent('download');
+ await downloadDocx(page);
+ const path = info.outputPath(name); await (await pending).saveAs(path);
+ return strFromU8(unzipSync(readFileSync(path))['word/settings.xml'] || new Uint8Array());
+ };
+ const before = await settings('before.docx');
+ await ribbon.getByRole('button', { name: 'Footnote and Endnote Settings', exact: true }).click();
+ const dialog = page.getByRole('dialog', { name: 'Footnote and Endnote' });
+ await dialog.getByRole('button', { name: 'Apply', exact: true }).click();
+ await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
+ expect(await settings('unchanged.docx')).toBe(before);
+ await ribbon.getByRole('button', { name: 'Footnote and Endnote Settings', exact: true }).click();
+ await dialog.getByLabel('Number format').selectOption('lowerRoman');
+ await dialog.getByRole('button', { name: 'Apply', exact: true }).click();
+ await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
+ const changed = await settings('changed.docx');
+ expect(changed).toContain('');
+ expect(changed).toContain('');
+ expect(changed).not.toContain('endnotePr');
+ expect(changed).not.toContain('numStart');
+});
diff --git a/src/documents/tests/browser/page-number-parts.spec.js b/src/documents/tests/browser/page-number-parts.spec.js
new file mode 100644
index 000000000..af640e8aa
--- /dev/null
+++ b/src/documents/tests/browser/page-number-parts.spec.js
@@ -0,0 +1,37 @@
+import { test, expect } from '@playwright/test';
+import { readFileSync } from 'node:fs';
+import { unzipSync, strFromU8 } from 'fflate';
+import { onTab, downloadDocx } from './helpers/editor-tools.js';
+
+// A footer saved with Word's page-number building block (a block docPartObj control) opens for editing:
+// typing in it, a new line and undo work, and the saved footer keeps the building block around its text.
+test('edits a page-number building-block footer and saves it inside its wrapper', async ({ page }, info) => {
+ const errors = []; page.on('pageerror', e => errors.push(e.message));
+ 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/page-number-parts.docx');
+ await expect(input).toHaveValue(/^Body page 1/);
+ await expect(input).toHaveJSProperty('readOnly', false);
+ // Page 2 shows the Bottom of Page footer.
+ await page.evaluate(() => { const e = window.__docs.current.view.engine, m = e.model; m.select(m.text.indexOf('Body page 2')); e.focus(); });
+ 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(() => page.evaluate(() => window.__docs.current.view.engine.session.path)).toBe('word/footer1.xml');
+ await page.evaluate(() => { const e = window.__docs.current.view.engine; e.model.select(0); e.focus(); });
+ await page.keyboard.insertText('Draft ');
+ await page.keyboard.press('Enter');
+ const story = () => page.evaluate(() => window.__docs.current.view.engine.model.paragraphs.map(p => p.runs.map(r => r.text).join('')));
+ await expect.poll(story).toEqual(['Draft ', '\uFFFC', '']);
+ // Both halves stay in the building block.
+ expect(await page.evaluate(() => window.__docs.current.view.engine.model.paragraphs.map(p => (p.blockControls || []).length))).toEqual([1, 1, 0]);
+ await page.keyboard.press('ControlOrMeta+z');
+ await expect.poll(story).toEqual(['Draft \uFFFC', '']);
+ const pending = page.waitForEvent('download');
+ await downloadDocx(page);
+ const path = info.outputPath('edited.docx'); await (await pending).saveAs(path);
+ const footer = strFromU8(unzipSync(readFileSync(path))['word/footer1.xml']);
+ expect(footer).toMatch(/.*].*Draft .*PAGE.*<\/w:p><\/w:sdtContent><\/w:sdt>]/);
+ expect(errors).toEqual([]);
+});
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([]);
+});
diff --git a/src/documents/tests/browser/pointer-selection.spec.js b/src/documents/tests/browser/pointer-selection.spec.js
index ab2cf7130..b45c12076 100644
--- a/src/documents/tests/browser/pointer-selection.spec.js
+++ b/src/documents/tests/browser/pointer-selection.spec.js
@@ -157,6 +157,37 @@ 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')); });
+ // Find and Replace: Ctrl+H, or Cmd+Shift+H on a Mac.
+ await input(page).press(process.platform === 'darwin' ? 'Meta+Shift+h' : 'Control+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');
diff --git a/src/documents/tests/browser/protected-page-stories.spec.js b/src/documents/tests/browser/protected-page-stories.spec.js
new file mode 100644
index 000000000..4f8ff5e6d
--- /dev/null
+++ b/src/documents/tests/browser/protected-page-stories.spec.js
@@ -0,0 +1,52 @@
+import { test, expect } from '@playwright/test';
+import { readFileSync } from 'node:fs';
+import { unzipSync, strFromU8 } from 'fflate';
+import { onTab, downloadDocx } from './helpers/editor-tools.js';
+
+// Under Restrict Editing (read only), References and Insert commands that add a header or footer or change
+// note and page-number settings leave the document as it is, as typing does, and say why.
+test('a protected document gets no new header, note settings or page numbering from the ribbon', 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('Locked contract text.');
+ await onTab(page, 'review');
+ await page.locator('#ribbon-panel-review [aria-label="Restrict Editing"]').evaluate(b => b.click());
+ const pane = page.getByRole('complementary', { name: 'Restrict Editing pane' });
+ await pane.getByRole('checkbox', { name: 'Allow only this type of editing in the document:' }).check();
+ await pane.getByRole('button', { name: 'Yes, Start Enforcing Protection' }).click();
+ await page.getByRole('dialog', { name: 'Start Enforcing Protection' }).getByRole('button', { name: 'OK', exact: true }).click();
+ await expect(pane).toContainText('protected from unintentional editing');
+ const stories = () => page.evaluate(() => Object.keys(window.__docs.current.view.engine.pageStories?.stories || {}));
+ // Edit Header in a document without one.
+ await onTab(page, 'insert');
+ await page.locator('#ribbon-panel-insert').getByRole('button', { name: 'Header', exact: true }).click();
+ await page.getByRole('menuitem', { name: 'Edit Header', exact: true }).click();
+ await expect(page.getByText('This document is protected against editing, so its headers cannot be changed.', { exact: false })).toBeVisible();
+ expect(await stories()).toEqual([]);
+ // Footnote numbering.
+ await onTab(page, 'references');
+ await page.locator('#ribbon-panel-references').getByRole('button', { name: 'Footnote and Endnote Settings', exact: true }).click();
+ const notes = page.getByRole('dialog', { name: 'Footnote and Endnote' });
+ await notes.getByLabel('Number format').selectOption('lowerRoman');
+ await notes.getByRole('button', { name: 'Apply', exact: true }).click();
+ await expect(notes.getByRole('alert')).toContainText('its footnote and endnote settings cannot be changed');
+ await notes.getByRole('button', { name: 'Cancel', exact: true }).click();
+ // Format Page Numbers.
+ await onTab(page, 'insert');
+ await page.locator('#ribbon-panel-insert').getByRole('button', { name: 'Page Number', exact: true }).click();
+ await page.getByRole('menuitem', { name: 'Format Page Numbers…', exact: true }).click();
+ const numbers = page.getByRole('dialog', { name: 'Page Number Format' });
+ await numbers.getByLabel('Number format').selectOption('lowerRoman');
+ await numbers.getByRole('button', { name: 'OK', exact: true }).click();
+ await expect(numbers.getByRole('alert')).toContainText('its page numbering cannot be changed');
+ await numbers.getByRole('button', { name: 'Cancel', exact: true }).click();
+ const pending = page.waitForEvent('download');
+ await downloadDocx(page);
+ const path = info.outputPath('protected.docx'); await (await pending).saveAs(path);
+ const parts = unzipSync(readFileSync(path));
+ expect(Object.keys(parts).filter(name => /header|footer/.test(name))).toEqual([]);
+ expect(strFromU8(parts['word/settings.xml'])).not.toContain('footnotePr');
+ expect(strFromU8(parts['word/document.xml'])).not.toContain('pgNumType');
+});
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.');
+});
diff --git a/src/documents/tests/browser/remove-page-numbers-section.spec.js b/src/documents/tests/browser/remove-page-numbers-section.spec.js
new file mode 100644
index 000000000..4de8432d7
--- /dev/null
+++ b/src/documents/tests/browser/remove-page-numbers-section.spec.js
@@ -0,0 +1,38 @@
+import { test, expect } from '@playwright/test';
+import { readFileSync, writeFileSync } from 'node:fs';
+import { unzipSync, zipSync, strToU8, strFromU8 } from 'fflate';
+import { onTab } from './helpers/editor-tools.js';
+
+// Remove Page Numbers works on the section whose footer is being edited, not on the section where the caret
+// was last left in the text.
+const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
+const footer = name => `${name} page 1`;
+const section = id => ``;
+
+test('Remove Page Numbers from a footer removes that section\'s page numbers', async ({ page }, info) => {
+ const parts = unzipSync(readFileSync('public/fixtures/toc-pageref.docx'));
+ const types = strFromU8(parts['[Content_Types].xml']);
+ parts['[Content_Types].xml'] = strToU8(types.replace('', ['one', 'two'].map(n => ``).join('') + ''));
+ parts['word/_rels/document.xml.rels'] = strToU8(strFromU8(parts['word/_rels/document.xml.rels']).replace('', ['one', 'two'].map(n => ``).join('') + ''));
+ parts['word/footer-one.xml'] = strToU8(footer('First section'));
+ parts['word/footer-two.xml'] = strToU8(footer('Second section'));
+ parts['word/document.xml'] = strToU8(`${section('fone')}Section oneSection two${section('ftwo')}`);
+ const path = info.outputPath('two-footers.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('Section one\nSection two');
+ await expect(input).toHaveJSProperty('readOnly', false);
+ const footers = () => page.evaluate(() => { const e = window.__docs.current.view.engine; return Object.fromEntries(Object.entries(e.pageStories.stories).map(([p, s]) => [p, s.paragraphs.map(q => q.runs.map(r => r.text).join('')).join('\n')])); });
+ // The caret was last in the first section's text; the second section's footer is open.
+ await page.evaluate(() => {
+ const e = window.__docs.current.view.engine; e.model.select(2);
+ const line = e.storyLayout.lines.find(l => e.pageStories.stories[l.pageStory]?.kind === 'footer' && l.pageStory.endsWith('footer-two.xml'));
+ e.editStory(line.pageStory, line.page); e.focus();
+ });
+ await onTab(page, 'insert');
+ await page.locator('#ribbon-panel-insert').getByRole('button', { name: 'Page Number', exact: true }).click();
+ await page.getByRole('menuitem', { name: 'Remove Page Numbers', exact: true }).click();
+ await expect.poll(footers).toEqual({ 'word/footer-one.xml': 'First section page ', 'word/footer-two.xml': '' });
+});
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([]);
+});
diff --git a/src/documents/tests/browser/ribbon-layout.spec.js b/src/documents/tests/browser/ribbon-layout.spec.js
index 45e615e81..baccc53c4 100644
--- a/src/documents/tests/browser/ribbon-layout.spec.js
+++ b/src/documents/tests/browser/ribbon-layout.spec.js
@@ -575,3 +575,68 @@ test('Escape closes a collapsed group\'s drop-down whose commands are all unavai
await expect(changes).toBeFocused();
} finally { await context.close(); }
});
+
+test('a tab the document chooses on a phone is scrolled into the tab row', async ({ page }) => {
+ await openEditor(page, 390);
+ await page.getByRole('textbox', { name: 'Document text' }).fill('Heading\nBody text');
+ const shown = name => page.evaluate(name => {
+ const tab = [...document.querySelectorAll('.ribbon-tab')].find(t => t.textContent.trim() === name), row = tab.closest('.ribbon-tabs');
+ const a = tab.getBoundingClientRect(), b = row.getBoundingClientRect();
+ return a.left >= b.left - 1 && a.right <= b.right + 1;
+ }, name);
+ // Outline view brings up its Outlining tab, at the end of the row.
+ await page.evaluate(() => window.__docs.current.view.commands.setDocumentView('outline'));
+ await expect(tab(page, 'Outlining')).toHaveAttribute('aria-selected', 'true');
+ await expect.poll(() => shown('Outlining')).toBe(true);
+ // Closing it goes back to a tab at the start of the row.
+ await page.getByRole('button', { name: 'Close Outline View' }).click();
+ await expect(tab(page, 'Outlining')).toBeHidden();
+ const selected = await page.locator('.ribbon-tab[aria-selected="true"]').textContent();
+ await expect.poll(() => shown(selected.trim())).toBe(true);
+});
+
+test('a phone held sideways folds the ribbon to its tabs, leaving the height to the page', async ({ page }) => {
+ await page.setViewportSize({ width: 844, height: 390 });
+ await page.goto('/mock.html#/new');
+ await page.locator('.ribbon-card').waitFor({ state: 'attached' });
+ const bold = page.locator('#ribbon-panel-home button[aria-label="Bold"]');
+ await expect(page.getByRole('tab', { name: 'Home', exact: true })).toBeVisible();
+ await expect(bold).toBeHidden();
+ // Most of the window is the document's.
+ const host = await page.locator('.canvas-host').first().boundingBox();
+ expect(host.height).toBeGreaterThan(390 / 2);
+ // A tab shows its commands over the page, as Show Tabs Only does.
+ await page.getByRole('tab', { name: 'Home', exact: true }).click();
+ await expect(bold).toBeVisible();
+ await bold.click();
+ await expect(bold).toBeHidden();
+ // Upright again, the ribbon is shown as before; the remembered choice was never changed.
+ await page.setViewportSize({ width: 390, height: 844 });
+ await expect(page.locator('.ed-workspace')).not.toHaveClass(/ribbon-tabs-only/);
+ expect(await page.evaluate(() => localStorage.getItem('documents:ribbon-mode'))).toBeNull();
+ // Sideways, Always Show keeps it shown.
+ await page.setViewportSize({ width: 844, height: 390 });
+ await expect(page.locator('.ed-workspace')).toHaveClass(/ribbon-tabs-only/);
+ await page.getByRole('button', { name: 'Always show the ribbon' }).click();
+ await expect(page.locator('.ed-workspace')).not.toHaveClass(/ribbon-tabs-only/);
+ await expect(bold).toBeVisible();
+});
+
+test('on a phone, Auto-hide\'s show-ribbon button leaves the document name as much room as before', async ({ browser }) => {
+ const width = async mode => {
+ const context = await browser.newContext({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
+ const page = await context.newPage();
+ await page.addInitScript(mode => { try { localStorage.setItem('documents:ribbon-mode', mode); } catch { /* storage unavailable */ } }, mode);
+ await page.goto('/mock.html#/new');
+ await page.locator('.ribbon-card').waitFor({ state: 'attached' });
+ await expect(page.getByRole('textbox', { name: 'Document name' })).toBeVisible();
+ if (mode === 'auto') await expect(page.getByRole('button', { name: 'Show the ribbon' })).toBeVisible();
+ await page.waitForTimeout(300);
+ const shown = await page.getByRole('textbox', { name: 'Document name' }).evaluate(el => el.clientWidth);
+ await context.close();
+ return shown;
+ };
+ const always = await width('always'), auto = await width('auto');
+ // Within a few pixels: the name shows its first letters either way (it was cut to one letter).
+ expect(auto).toBeGreaterThanOrEqual(always - 10);
+});
diff --git a/src/documents/tests/browser/row-heights.spec.js b/src/documents/tests/browser/row-heights.spec.js
index 7713d21fd..00a7011a5 100644
--- a/src/documents/tests/browser/row-heights.spec.js
+++ b/src/documents/tests/browser/row-heights.spec.js
@@ -65,14 +65,23 @@ test('reflows tall minimum rows, preserves mixed no-ops and keeps zoomed Unicode
await input.press('ArrowRight'); expect(await input.evaluate(el => el.selectionStart)).toBe(caret.at + 2); await page.keyboard.insertText('X'); await undo.click(); await expect(input).toHaveValue(original);
});
-test('rolls back oversized repeated headers and keeps the dialog available for correction', async ({ page }) => {
+test('applies a repeated header row height taller than the page without repeating it, with one undo', async ({ page }) => {
+ const errors = []; page.on('pageerror', e => errors.push(e.message));
const input = await fixture(page), text = await input.inputValue(), count = await page.locator('canvas.canvas-page').count(), undo = page.getByRole('button', { name: 'Undo', exact: true });
- await select(input, 'Header height'); const range = await input.evaluate(el => [el.selectionStart, el.selectionEnd]); const d = await open(page);
+ const title = async () => (await onTab(page, 'tableLayout')).getByRole('button', { name: 'Row height', exact: true }).getAttribute('title');
+ await select(input, 'Header height'); const range = await input.evaluate(el => [el.selectionStart, el.selectionEnd]); let d = await open(page);
+ // A header row too tall to leave its page room for the body is no longer refused: it is laid out once,
+ // limited to a page, and the rows after it are not preceded by a repeated copy.
await d.getByRole('spinbutton', { name: 'Minimum row height (pt)' }).fill('1584'); await d.getByRole('button', { name: 'Apply', exact: true }).click();
- await expect(d.getByRole('alert')).toContainText('cannot fit'); await expect(undo).toBeDisabled(); await expect(input).toHaveValue(text); expect(await page.locator('canvas.canvas-page').count()).toBe(count);
- expect(await input.evaluate(el => [el.selectionStart, el.selectionEnd])).toEqual(range); await expect((await onTab(page, 'tableLayout')).getByRole('button', { name: 'Row height', exact: true })).toHaveAttribute('title', 'Row height: At least 36 pt');
- await d.getByRole('spinbutton', { name: 'Minimum row height (pt)' }).fill('42'); await d.getByRole('button', { name: 'Apply', exact: true }).click(); await expect(d).toHaveCount(0); await expect(input).toBeFocused();
- await undo.click(); await expect(undo).toBeDisabled();
+ await expect(d).toHaveCount(0); await expect(input).toBeFocused(); await expect(page.locator('.toast')).toHaveCount(0);
+ await expect(input).toHaveValue(text); expect(await input.evaluate(el => [el.selectionStart, el.selectionEnd])).toEqual(range);
+ expect(await title()).toBe('Row height: At least 1584 pt'); await expect.poll(() => page.locator('canvas.canvas-page').count()).toBeGreaterThan(count); await expect(undo).toBeEnabled();
+ // One undo restores the height, the pagination and the selection.
+ await undo.click(); expect(await title()).toBe('Row height: At least 36 pt'); await expect.poll(() => page.locator('canvas.canvas-page').count()).toBe(count);
+ await expect(undo).toBeDisabled(); expect(await input.evaluate(el => [el.selectionStart, el.selectionEnd])).toEqual(range);
+ d = await open(page); await d.getByRole('spinbutton', { name: 'Minimum row height (pt)' }).fill('42'); await d.getByRole('button', { name: 'Apply', exact: true }).click(); await expect(d).toHaveCount(0); await expect(input).toBeFocused();
+ expect(await title()).toBe('Row height: At least 42 pt');
+ await undo.click(); await expect(undo).toBeDisabled(); expect(errors).toEqual([]);
});
test('keeps row-height controls reachable on a narrow screen', async ({ page }, info) => {
diff --git a/src/documents/tests/browser/rulers.spec.js b/src/documents/tests/browser/rulers.spec.js
index debd3f2fc..6a09284c2 100644
--- a/src/documents/tests/browser/rulers.spec.js
+++ b/src/documents/tests/browser/rulers.spec.js
@@ -163,3 +163,24 @@ test('a phone-sized window that grows shows the rulers by default', async ({ pag
await page.setViewportSize({ width: 1280, height: 800 });
await expect(page.getByRole('group', { name: 'Horizontal ruler' })).toBeVisible();
});
+
+test('contrast themes keep the ruler\'s marks, indent markers and text area visible', async ({ page }) => {
+ await page.emulateMedia({ forcedColors: 'active' });
+ await open(page);
+ // Contrast themes replace backgrounds with the window color; whatever is drawn as a fill must keep a color of its own.
+ const fills = await page.evaluate(() => {
+ const ruler = document.querySelector('.canvas-ruler-horizontal');
+ const ground = getComputedStyle(ruler).backgroundColor;
+ const fill = node => node ? getComputedStyle(node).backgroundColor : null;
+ return {
+ ground,
+ tick: fill(ruler.querySelector('.canvas-ruler-tick.half')),
+ firstLine: fill(ruler.querySelector('[data-kind="firstLine"]')),
+ hanging: fill(ruler.querySelector('[data-kind="hanging"]')),
+ // The text area is told from the margins by an edge when fills are gone.
+ writingEdge: getComputedStyle(ruler.querySelector('.canvas-ruler-writing')).borderLeftStyle,
+ };
+ });
+ for (const part of ['tick', 'firstLine', 'hanging']) expect(fills[part], part).not.toBe(fills.ground);
+ expect(fills.writingEdge).not.toBe('none');
+});
diff --git a/src/documents/tests/browser/spelling.spec.js b/src/documents/tests/browser/spelling.spec.js
index 6d8b1c038..d70ff1c57 100644
--- a/src/documents/tests/browser/spelling.spec.js
+++ b/src/documents/tests/browser/spelling.spec.js
@@ -136,3 +136,58 @@ 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);
+});
+
+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');
+});
+
+test('words with optional hyphens are checked without them, and corrected whole', async ({ page }) => {
+ // Manual hyphenation puts optional hyphens inside words.
+ const input = await newDocument(page, 'Manual hyphenation keeps words recieved whole.');
+ await expect(proofing(page)).toHaveAttribute('aria-label', /1 error found/);
+ const menu = await wordMenu(page, input, 'recie');
+ await expect(menu.getByRole('menuitem').first()).toHaveText('received');
+ await menu.getByRole('menuitem', { name: 'received', exact: true }).click();
+ await expect(input).toHaveValue('Manual hyphenation keeps words received whole.');
+ await expect(proofing(page)).toHaveAttribute('aria-label', 'Spelling: no errors');
+});
diff --git a/src/documents/tests/browser/status-integration.spec.js b/src/documents/tests/browser/status-integration.spec.js
index 5cd501dd5..8f96a922e 100644
--- a/src/documents/tests/browser/status-integration.spec.js
+++ b/src/documents/tests/browser/status-integration.spec.js
@@ -34,3 +34,43 @@ test('Find Next steps through matches, and Go To keeps its own chord', async ({
await text(page).press(mac ? 'Meta+Alt+g' : 'Control+g');
await expect(page.getByRole('dialog', { name: 'Go To' })).toBeVisible();
});
+
+test('the page count follows the pages scrolled into view, then the caret again', async ({ page }) => {
+ await openEditor(page);
+ await text(page).fill(('Paragraph of text that fills the pages. '.repeat(40) + '\n').repeat(20));
+ await text(page).evaluate(el => { el.focus(); el.setSelectionRange(0, 0); el.dispatchEvent(new Event('select')); });
+ const count = page.locator('.ed-statusbar .ed-page-count');
+ await expect(count).toHaveText(/^Page 1 of (\d+)$/);
+ const total = Number((await count.textContent()).match(/of (\d+)/)[1]);
+ expect(total).toBeGreaterThan(3);
+ const host = page.locator('.canvas-host').first();
+ // Scrolled to the end: the last page is the one in view.
+ await host.evaluate(h => { h.scrollTop = h.scrollHeight; });
+ await expect(count).toHaveText(`Page ${total} of ${total}`);
+ // Half way: a page in the middle.
+ await host.evaluate(h => { h.scrollTop = (h.scrollHeight - h.clientHeight) / 2; });
+ await expect.poll(async () => Number((await count.textContent()).match(/Page (\d+)/)[1])).toBeGreaterThan(1);
+ // Typing goes back to the caret's page.
+ await text(page).press('ArrowRight');
+ await expect(count).toHaveText(`Page 1 of ${total}`);
+});
+
+test('Zoom In and Zoom Out are unavailable at the largest and smallest zoom, and keep the keyboard', async ({ page }) => {
+ await openEditor(page);
+ await text(page).fill('Zoom limits.');
+ const zoomIn = page.getByRole('button', { name: 'Zoom In' }), zoomOut = page.getByRole('button', { name: 'Zoom Out' });
+ const slider = page.getByRole('slider', { name: 'Zoom slider' });
+ await expect(zoomIn).not.toHaveAttribute('aria-disabled', 'true');
+ await zoomIn.focus();
+ for (let i = 0; i < 12; i++) await page.keyboard.press('Enter');
+ await expect(slider).toHaveValue('200');
+ await expect(zoomIn).toHaveAttribute('aria-disabled', 'true');
+ // Still on the button, which says it does nothing now, and pressing it changes nothing.
+ await expect(zoomIn).toBeFocused();
+ await page.keyboard.press('Enter');
+ await expect(slider).toHaveValue('200');
+ await expect(zoomOut).not.toHaveAttribute('aria-disabled', 'true');
+ await slider.fill('10');
+ await expect(zoomOut).toHaveAttribute('aria-disabled', 'true');
+ await expect(zoomIn).not.toHaveAttribute('aria-disabled', 'true');
+});
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');
+});
diff --git a/src/documents/tests/browser/story-field-update.spec.js b/src/documents/tests/browser/story-field-update.spec.js
new file mode 100644
index 000000000..10ca0be4e
--- /dev/null
+++ b/src/documents/tests/browser/story-field-update.spec.js
@@ -0,0 +1,30 @@
+import { test, expect } from '@playwright/test';
+import { readFileSync, writeFileSync } from 'node:fs';
+import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
+import { downloadDocx } from './helpers/editor-tools.js';
+
+// F9 in a header updates that header's DATE and FILENAME fields, and the update saves.
+test('F9 in a header updates its date and file name fields', async ({ page }, info) => {
+ const parts = unzipSync(readFileSync('public/fixtures/page-number-parts.docx'));
+ const complex = (code, cached) => ` ${code} ${cached}`;
+ parts['word/header1.xml'] = strToU8(`Printed ${complex('DATE \\@ "yyyy-MM-dd"', '2000-01-01')} · ${complex('FILENAME', 'old.docx')}`);
+ const path = info.outputPath('header-fields.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(/^Body page 1/);
+ await expect(input).toHaveJSProperty('readOnly', false);
+ const fields = () => page.evaluate(() => window.__docs.current.view.engine.pageStories.stories['word/header1.xml'].paragraphs[0].runs.filter(r => r.field).map(r => r.field.cachedText));
+ await page.evaluate(() => { const e = window.__docs.current.view.engine, line = e.storyLayout.lines.find(l => l.pageStory === 'word/header1.xml'); e.editStory('word/header1.xml', line.page); e.model.select(2); e.focus(); });
+ expect(await fields()).toEqual(['2000-01-01', 'old.docx']);
+ await page.keyboard.press('F9');
+ const today = await page.evaluate(() => { const d = new Date(), pad = n => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; });
+ await expect.poll(fields).toEqual([today, expect.stringMatching(/\.docx$/)]);
+ expect((await fields())[1]).not.toBe('old.docx');
+ expect(await page.evaluate(() => [window.__docs.current.view.engine.session.path, window.__docs.current.view.engine.model.range])).toEqual(['word/header1.xml', [2, 2]]);
+ const pending = page.waitForEvent('download');
+ await downloadDocx(page);
+ const saved = info.outputPath('saved.docx'); await (await pending).saveAs(saved);
+ expect(strFromU8(unzipSync(readFileSync(saved))['word/header1.xml'])).toContain(`${today}`);
+});
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..78a77473a
--- /dev/null
+++ b/src/documents/tests/browser/story-internal-links.spec.js
@@ -0,0 +1,51 @@
+import { test, expect } from '@playwright/test';
+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.
+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]);
+});
+
+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>/);
+});
diff --git a/src/documents/tests/browser/strikethrough.spec.js b/src/documents/tests/browser/strikethrough.spec.js
index b129d3f53..bf821e702 100644
--- a/src/documents/tests/browser/strikethrough.spec.js
+++ b/src/documents/tests/browser/strikethrough.spec.js
@@ -44,5 +44,5 @@ test('maps native strike formatting input to the same undoable document mutation
test('previews invalid or conflicting strike declarations as read-only with the original available', async ({ page }) => {
await page.goto('/playground.html'); await showAllTools(page); const input = page.getByRole('textbox', { name: 'Document text' }); await expect(input).toHaveValue(/^A small beginning/); const text = await input.inputValue();
- for (const [xml, message] of [['', /invalid strike/], ['', /strike and dstrike/]]) { const parts = unzipSync(readFileSync('public/fixtures/strikethrough.docx')); parts['word/document.xml'] = strToU8(strFromU8(parts['word/document.xml']).replace('', xml)); await openReadOnlyPreview(page, { name: 'bad-strike.docx', mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', buffer: Buffer.from(zipSync(parts)) }); }
+ for (const [xml, message] of [['', /invalid strike/], ['', /strike and dstrike both on/]]) { const parts = unzipSync(readFileSync('public/fixtures/strikethrough.docx')); parts['word/document.xml'] = strToU8(strFromU8(parts['word/document.xml']).replace('', xml)); await openReadOnlyPreview(page, { name: 'bad-strike.docx', mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', buffer: Buffer.from(zipSync(parts)) }); }
});
diff --git a/src/documents/tests/browser/table-block-tracked-cut.spec.js b/src/documents/tests/browser/table-block-tracked-cut.spec.js
index 6a7bb0755..c1efd41b5 100644
--- a/src/documents/tests/browser/table-block-tracked-cut.spec.js
+++ b/src/documents/tests/browser/table-block-tracked-cut.spec.js
@@ -44,3 +44,31 @@ test('Cut and Delete on selected cells are tracked with Track Changes on', async
expect(await cells(page)).toEqual([['1', '2', '3(del)'], ['4', '5', '6(del)'], ['7', '8', '9(del)']]);
expect(errors).toEqual([]);
});
+
+test('with Track Changes on, deleting rows explains it cannot be tracked and leaves the table', 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 onTab(page, 'insert');
+ await page.locator('#ribbon-panel-insert').getByRole('button', { name: 'Insert table', exact: true }).click();
+ await page.getByRole('menuitem', { name: '2 columns, 2 rows' }).click();
+ for (const [i, value] of ['1', '2', '3', '4'].entries()) { if (i) await input.press('Tab'); await page.keyboard.type(value); }
+ const track = (await onTab(page, 'review')).getByRole('button', { name: 'Track Changes', exact: true });
+ await track.click();
+ await expect(track).toHaveAttribute('aria-pressed', 'true');
+ await onTab(page, 'tableLayout');
+ await page.locator('.ribbon-card').getByRole('button', { name: 'Delete', exact: true }).click();
+ await page.getByRole('menuitem', { name: 'Delete Rows' }).click();
+ await expect(page.getByText(/Deleting table rows cannot be tracked yet\. Turn off Track Changes/)).toBeVisible();
+ expect(await cells(page)).toEqual([['1', '2'], ['3', '4']]);
+ // Track Changes off: it deletes.
+ await (await onTab(page, 'review')).getByRole('button', { name: 'Track Changes', exact: true }).click();
+ await onTab(page, 'tableLayout');
+ await page.locator('.ribbon-card').getByRole('button', { name: 'Delete', exact: true }).click();
+ await page.getByRole('menuitem', { name: 'Delete Rows' }).click();
+ await expect.poll(() => cells(page)).toEqual([['1', '2']]);
+ expect(errors).toEqual([]);
+});
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);
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']]);
+});
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);
+});
diff --git a/src/documents/tests/browser/table-tools.spec.js b/src/documents/tests/browser/table-tools.spec.js
index ac92a6186..09a24a953 100644
--- a/src/documents/tests/browser/table-tools.spec.js
+++ b/src/documents/tests/browser/table-tools.spec.js
@@ -206,12 +206,32 @@ 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();
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);
diff --git a/src/documents/tests/browser/title-bar.spec.js b/src/documents/tests/browser/title-bar.spec.js
index 63755cc2a..8ffb356da 100644
--- a/src/documents/tests/browser/title-bar.spec.js
+++ b/src/documents/tests/browser/title-bar.spec.js
@@ -167,3 +167,28 @@ test('the document details popover holds its fields and buttons inside its frame
await page.keyboard.press('Escape');
}
});
+
+test('the document name stays put while AutoSave goes from unsaved to saving to saved', async ({ browser }) => {
+ // Four fresh editors, each waiting for an autosave.
+ test.setTimeout(120000);
+ for (const width of [1280, 1440, 1600, 1760]) {
+ // A page of its own each time: a second "Untitled document" would be renamed by its first save.
+ const page = await browser.newPage();
+ await openEditor(page, width);
+ // Every state the save status passes through, with where the name then starts.
+ await page.evaluate(() => {
+ window.titleAt = [];
+ const name = document.querySelector('.ed-titlebar .canvas-title'), status = document.querySelector('.ed-titlebar .canvas-save-status');
+ new MutationObserver(() => window.titleAt.push([status.getAttribute('aria-label'), Math.round(name.getBoundingClientRect().left)]))
+ .observe(status, { attributes: true, childList: true, subtree: true });
+ });
+ await text(page).pressSequentially('Draft', { delay: 30 });
+ await expect(page.locator('.ed-titlebar .canvas-save-status')).toHaveAttribute('aria-label', 'Saved to Puter');
+ const seen = await page.evaluate(() => window.titleAt);
+ expect(new Set(seen.map(([state]) => state)), `${width}px`).toEqual(new Set(['Unsaved changes', 'Saving…', 'Saved to Puter']));
+ expect([...new Set(seen.map(([, left]) => left))], `${width}px: ${JSON.stringify(seen)}`).toHaveLength(1);
+ // And the name is shown whole.
+ expect(await title(page).evaluate(el => el.scrollWidth <= el.clientWidth), `${width}px`).toBe(true);
+ await page.close();
+ }
+});
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/);
+});
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 / {
+ await blank(page);
+ const long = 'This paragraph is long enough to wrap onto a second line of the page, so that the end of its first line is also where the second begins.';
+ await input(page).fill(`Intro\n${long}`);
+ const lines = await page.evaluate(() => window.__docs.current.view.engine.selectionLayout.lines.map(l => [l.start, l.end]));
+ const [, first] = lines;
+ await input(page).evaluate((el, at) => { el.setSelectionRange(at, at); el.dispatchEvent(new Event('select')); }, first[0]);
+ // End puts the caret at the end of the first line; typing there and undoing brings it back to that place.
+ await input(page).press('End');
+ expect(await input(page).evaluate(el => el.selectionStart)).toBe(first[1]);
+ await page.keyboard.type('X');
+ await input(page).press('ControlOrMeta+z');
+ expect(await input(page).evaluate(el => el.selectionStart)).toBe(first[1]);
+ expect(await page.evaluate(() => window.__docs.current.view.engine.caretAffinity)).toBe('backward');
+ // Home goes to the start of that first line.
+ await input(page).press('Home');
+ expect(await input(page).evaluate(el => el.selectionStart)).toBe(first[0]);
+ // Redo puts it back after the typed letter.
+ await input(page).press('ControlOrMeta+Shift+z');
+ expect(await input(page).inputValue()).toContain('X');
+});
diff --git a/src/documents/tests/browser/vml-watermarks.spec.js b/src/documents/tests/browser/vml-watermarks.spec.js
new file mode 100644
index 000000000..a228f7bb5
--- /dev/null
+++ b/src/documents/tests/browser/vml-watermarks.spec.js
@@ -0,0 +1,52 @@
+import { test, expect } from '@playwright/test';
+import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
+import { unzipSync, strFromU8 } from 'fflate';
+import { downloadDocx } from './helpers/editor-tools.js';
+
+// A document with Word watermarks (VML in its headers) opens for editing; the watermarks show behind
+// the text of the pages whose header holds them and are saved as they were.
+const FIXTURE = 'tests/fixtures/vml-watermarks/watermarks.docx';
+/** Colours in a square around the centre of a page canvas: counts of greys and of tinted pixels. */
+const centre = (page, index) => page.evaluate(index => {
+ const canvas = document.querySelectorAll('canvas.canvas-page')[index], ctx = canvas.getContext('2d');
+ const size = Math.round(canvas.width / 4), data = ctx.getImageData(Math.round(canvas.width / 2 - size / 2), Math.round(canvas.height / 2 - size / 2), size, size).data;
+ let grey = 0, tinted = 0;
+ for (let i = 0; i < data.length; i += 4) {
+ const [r, g, b] = [data[i], data[i + 1], data[i + 2]];
+ if (Math.max(r, g, b) - Math.min(r, g, b) > 20) tinted++; else if (r < 245) grey++;
+ }
+ return { grey, tinted };
+}, index);
+
+test('Word watermarks open for editing, show behind the text and save unchanged', async ({ page }, info) => {
+ const errors = []; page.on('pageerror', e => errors.push(e.message));
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.goto('/playground.html');
+ await page.getByRole('button', { name: 'New', exact: true }).click();
+ const input = page.getByRole('textbox', { name: 'Document text' });
+ page.once('dialog', dialog => dialog.accept());
+ await page.getByLabel('Open DOCX file').setInputFiles(FIXTURE);
+ await expect(input).toHaveValue('Body text.');
+ await expect(page.getByText('Read only', { exact: true })).toHaveCount(0);
+ // Page 1 has the first-page header: the washed-out picture (red and blue) behind the text.
+ await expect.poll(() => centre(page, 0)).toMatchObject({ tinted: expect.any(Number) });
+ expect((await centre(page, 0)).tinted).toBeGreaterThan(100);
+ // A second page shows the default header's silver CONFIDENTIAL.
+ await input.press('ControlOrMeta+End');
+ for (let i = 0; i < 45; i++) await input.press('Enter');
+ await page.keyboard.type('Second page text.');
+ await expect(page.locator('canvas.canvas-page')).toHaveCount(2);
+ await page.locator('canvas.canvas-page').nth(1).scrollIntoViewIfNeeded();
+ await expect.poll(async () => (await centre(page, 1)).grey).toBeGreaterThan(500);
+ expect((await centre(page, 1)).tinted).toBe(0);
+ if (process.env.WATERMARK_SHOTS) await page.screenshot({ path: `${process.env.WATERMARK_SHOTS}/watermarks.png` });
+
+ // Saved: both headers as they were.
+ const path = info.outputPath('watermarks.docx');
+ const download = page.waitForEvent('download'); await downloadDocx(page); await (await download).saveAs(path);
+ mkdirSync('test-results/vml-watermarks', { recursive: true }); writeFileSync('test-results/vml-watermarks/watermarks.docx', readFileSync(path));
+ const saved = unzipSync(new Uint8Array(readFileSync(path))), original = unzipSync(new Uint8Array(readFileSync(FIXTURE)));
+ for (const part of ['word/header1.xml', 'word/header2.xml', 'word/media/watermark.png']) expect(strFromU8(saved[part]), part).toBe(strFromU8(original[part]));
+ expect(strFromU8(saved['word/document.xml'])).toContain('Second page text.');
+ expect(errors).toEqual([]);
+});
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);
+});
diff --git a/src/documents/tests/canvas-backing-size.test.js b/src/documents/tests/canvas-backing-size.test.js
new file mode 100644
index 000000000..2fdab666d
--- /dev/null
+++ b/src/documents/tests/canvas-backing-size.test.js
@@ -0,0 +1,22 @@
+import { it, expect } from 'vitest';
+import { pageBackingSize, MAX_CANVAS_PIXELS } from '../src/engine/canvas-backing.js';
+
+it('gives a page canvas the full device resolution while it is within the canvas pixel limit', () => {
+ expect(pageBackingSize(816, 1056, 1)).toEqual({ width: 816, height: 1056 });
+ expect(pageBackingSize(1632, 2112, 2)).toEqual({ width: 3264, height: 4224 });
+ expect(pageBackingSize(1061, 1373, 1.25)).toEqual({ width: 1326, height: 1716 });
+});
+
+it('lowers the resolution of a page that would exceed the limit instead of leaving it blank', () => {
+ // A Letter page at 200% on a 3x phone: 4896 × 6336 device pixels, about 31 million, while
+ // Safari on iOS draws nothing in a canvas larger than 4096 × 4096 pixels.
+ const size = pageBackingSize(1632, 2112, 3);
+ expect(size.width * size.height).toBeLessThanOrEqual(MAX_CANVAS_PIXELS);
+ expect(size.width * size.height).toBeGreaterThan(MAX_CANVAS_PIXELS * 0.99);
+ // Both sides shrink by the same factor, so painting keeps one scale on both axes.
+ expect(Math.abs(size.width / 1632 - size.height / 2112)).toBeLessThan(1 / 1632 + 1 / 2112);
+ // The largest page at the highest zoom on a 3x display still fits.
+ const largest = pageBackingSize(2112 * 2, 2112 * 2, 3);
+ expect(largest.width * largest.height).toBeLessThanOrEqual(MAX_CANVAS_PIXELS);
+ expect(largest.width).toBeGreaterThan(0);
+});
diff --git a/src/documents/tests/canvas-bookmarks.test.js b/src/documents/tests/canvas-bookmarks.test.js
index 515515994..90f401ab9 100644
--- a/src/documents/tests/canvas-bookmarks.test.js
+++ b/src/documents/tests/canvas-bookmarks.test.js
@@ -100,7 +100,7 @@ it('has zero layout width and preserves source caret positions, selection and re
const p=await DocxPackage.open(source),d=model(p),before=layout(d);expect(before.repeatedLines.length).toBeGreaterThan(0);const link=hyperlinkRanges(d.paragraphs).find(r=>d.text.slice(r.start,r.end)==='Header link'),rects=selectionRects(before,link.start,link.end);expect(rects.length).toBeGreaterThan(1);for(const r of rects)expect(hyperlinkAtPoint(before,d.paragraphs,r.page,r.x+r.width/2,r.y+r.height/2)?.link.anchor).toBe('Target');const pos=entry(d,'Target').start+9,c=caretRect(before,pos);expect(hitTest(before,c.page,c.x,c.y+c.height/2)).toBe(pos);for(const b of [...d.bookmarks])d.removeBookmark(b.id);expect(layout(d)).toEqual(before);
});
-it.each(['', '1bad','two words','éclair','a'.repeat(41)])('rejects invalid authored bookmark names without changing text/history: %s',name=>{const d=new TextDocument([paragraph('text')]),before=d.snapshot();expect(()=>d.addBookmark(name)).toThrow();expect(d.snapshot()).toEqual(before);expect(d.history).toHaveLength(0);});
+it.each(['', '1bad','two words','a-b','a'.repeat(41)])('rejects invalid authored bookmark names without changing text/history: %s',name=>{const d=new TextDocument([paragraph('text')]),before=d.snapshot();expect(()=>d.addBookmark(name)).toThrow();expect(d.snapshot()).toEqual(before);expect(d.history).toHaveLength(0);});
it.each([
['unmatched start',xml=>elements(xml,'bookmarkEnd')[0].remove()],
@@ -113,3 +113,15 @@ it.each([
['invalid displaced marker',xml=>starts(xml)[0].setAttributeNS(W,'w:displacedByCustomXml','sideways')],
['marker content',xml=>starts(xml)[0].append(xml.createCDATASection('text'))],
])('rejects unsupported %s before editable open',async(label,change)=>{await expect(DocxPackage.open(revise(change))).rejects.toThrow(/bookmark/i);});
+
+it('adds and renames bookmarks named with letters of any script, as word processors allow',async()=>{
+ const p=await DocxPackage.open(source),d=model(p);
+ select(d,'Bookmarks');d.addBookmark('Закладка');
+ const target=contents(d,'Target');d.renameBookmark(entry(d,'Target').id,'Résumé_2');
+ d.select(0);d.addBookmark('書籤');
+ // A decomposed accent belongs to its letter; 40 UTF-16 units at most, as the format's readers count them.
+ d.select(1);d.addBookmark('café');d.select(2);d.addBookmark('я'.repeat(40));
+ const out=model(await DocxPackage.open(await save(p,d)));
+ expect(contents(out,'Закладка')).toBe('Bookmarks');expect(contents(out,'Résumé_2')).toBe(target);expect(contents(out,'書籤')).toBe('');expect(contents(out,'café')).toBe('');
+ for(const name of ['1st','with space','x'.repeat(41),'я'.repeat(41),'-dash','́accent','a-b'])expect(()=>d.addBookmark(name),name).toThrow();
+});
diff --git a/src/documents/tests/canvas-dark-page.test.js b/src/documents/tests/canvas-dark-page.test.js
new file mode 100644
index 000000000..edd711f55
--- /dev/null
+++ b/src/documents/tests/canvas-dark-page.test.js
@@ -0,0 +1,28 @@
+import { describe, it, expect } from 'vitest';
+import { darkPageRgb, darkPageColor } from '../src/engine/canvas-dark-page.js';
+
+const lum = ([r, g, b]) => [r, g, b].map(v => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }).reduce((s, v, i) => s + v * [0.2126, 0.7152, 0.0722][i], 0);
+const contrast = (a, b) => { const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05); };
+
+describe('dark page colors', () => {
+ it('turns white paper near-black and black ink near-white, readably', () => {
+ const paper = darkPageRgb([255, 255, 255]), ink = darkPageRgb([0, 0, 0]);
+ expect(paper).toEqual([28, 28, 28]);
+ expect(ink).toEqual([227, 227, 227]);
+ expect(contrast(paper, ink)).toBeGreaterThan(10);
+ });
+ it('keeps grays gray and a color its hue, and keeps ink readable on the paper', () => {
+ const [r, g, b] = darkPageRgb([128, 128, 128]);
+ expect(r).toBe(g); expect(g).toBe(b);
+ const red = darkPageRgb([192, 0, 0]);
+ expect(red[0]).toBeGreaterThan(red[1]); expect(red[0]).toBeGreaterThan(red[2]);
+ // Dark blue headings and red text stay readable on the dark paper.
+ for (const color of [[31, 56, 100], [192, 0, 0], [68, 84, 106]]) expect(contrast(darkPageRgb(color), darkPageRgb([255, 255, 255]))).toBeGreaterThan(4.5);
+ });
+ it('keeps a color\'s transparency and passes anything it cannot read', () => {
+ const parse = value => ({ '#ffffff': [255, 255, 255, 1], 'rgba(0, 0, 0, 0.5)': [0, 0, 0, 0.5] })[value] || null;
+ expect(darkPageColor('#ffffff', parse)).toBe('#1c1c1c');
+ expect(darkPageColor('rgba(0, 0, 0, 0.5)', parse)).toBe('rgba(227, 227, 227, 0.5)');
+ expect(darkPageColor('not a color', parse)).toBe('not a color');
+ });
+});
diff --git a/src/documents/tests/canvas-docx.test.js b/src/documents/tests/canvas-docx.test.js
index 13c77027f..c36e952cf 100644
--- a/src/documents/tests/canvas-docx.test.js
+++ b/src/documents/tests/canvas-docx.test.js
@@ -161,7 +161,7 @@ describe('DOCX paragraph formatting', () => {
it.each([
'', '',
'', '',
- '',
+ '',
])('rejects unsupported effective formatting: %s', async properties => {
await expect(DocxPackage.open(await fixture(`${properties}One`))).rejects.toMatchObject({ name: 'UnsupportedDocumentError' });
});
diff --git a/src/documents/tests/canvas-indents-tabs.test.js b/src/documents/tests/canvas-indents-tabs.test.js
index 0740eeb5b..30ab8960f 100644
--- a/src/documents/tests/canvas-indents-tabs.test.js
+++ b/src/documents/tests/canvas-indents-tabs.test.js
@@ -228,7 +228,8 @@ describe('DOCX indent and tab conformance', () => {
const reopened = await DocxPackage.open(await bytes(pkg.export(doc.paragraphs)));
expect(reopened.paragraphs[0].format.firstLine).toBe(540);
const uncleared = await fixture({ styles: '', pPr: '' });
- await expect(DocxPackage.open(uncleared)).rejects.toThrow(/character-unit/);
+ // The inherited character-unit hanging indent overrides the direct twips (§17.3.1.12); tests/character-unit-indents.test.js covers the sizes.
+ expect((await DocxPackage.open(uncleared)).paragraphs[0].format.firstLine).toBeLessThan(0);
});
it('clears all inherited tabs using schema-valid clear records and inherits stops through an empty source set', async () => {
const pkg = await DocxPackage.open(await fixture({ styles: '', pPr: '' }));
@@ -238,7 +239,7 @@ describe('DOCX indent and tab conformance', () => {
expect(strFromU8(parts['word/document.xml'])).toContain('w:val="clear" w:pos="720"');
expect((await DocxPackage.open(zipSync(parts))).paragraphs[0].format.tabs).toEqual([]);
});
- it.each(['', '', '', ''])('rejects unsupported or invalid geometry instead of flattening it: %s', async pPr => {
+ it.each(['', '', ''])('rejects unsupported or invalid geometry instead of flattening it: %s', async pPr => {
await expect(DocxPackage.open(await fixture({ pPr }))).rejects.toThrow();
});
it('preserves legacy list tabs without using them as ordinary text tabs', async () => {
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-justification.test.js b/src/documents/tests/canvas-justification.test.js
index 925102455..d8eb79d18 100644
--- a/src/documents/tests/canvas-justification.test.js
+++ b/src/documents/tests/canvas-justification.test.js
@@ -90,10 +90,17 @@ it('shares expanded advances across mixed runs, decorations, scripts, small caps
const unicode = layout(simple('A\u0301😀 one two\vFinal')).lines[0]; expect(unicode.stops.map(s => s.offset)).toEqual([0, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
});
-it('anchors earlier tab fields and rejects expansion of non-left final fields', () => {
+it('anchors earlier tab fields and leaves lines ending in a non-left tab field natural', () => {
const doc = simple('One two\tA B\vFinal', {}, { tabs: [{ position: 1800, alignment: 'left' }] }); const first = layout(doc).lines[0];
expect(first.stops.find(s => s.offset === 4).x).toBe(80); expect(first.stops.find(s => s.offset === 8).x).toBe(168); expect(edge(first)).toBeCloseTo(312);
- for (const alignment of ['center', 'right', 'decimal']) expect(() => layout(simple('Label\tA B\vFinal', {}, { tabs: [{ position: 1800, alignment }] }))).toThrow(/justification after/);
+ // After a center, right or decimal tab the line keeps its natural spacing: the aligned field stays on
+ // its stop rather than the document being refused.
+ for (const alignment of ['center', 'right', 'decimal']) {
+ const natural = layout(simple('Label\tA B\vFinal', {}, { alignment: 'left', tabs: [{ position: 1800, alignment }] })).lines[0];
+ const line = layout(simple('Label\tA B\vFinal', {}, { tabs: [{ position: 1800, alignment }] })).lines[0];
+ expect(line.justificationSpacing).toBeUndefined();
+ expect(line.stops.map(s => s.x)).toEqual(natural.stops.map(s => s.x));
+ }
expect(() => layout(simple('Label\tA B', {}, { tabs: [{ position: 1800, alignment: 'right' }] }))).not.toThrow();
});
@@ -150,14 +157,23 @@ it('edits a shared style, preserves direct alignment overrides, and splits/merge
expect(newDoc.text).toBe('Alpha beta'); expect(model(await DocxPackage.open(await save(fresh, newDoc))).paragraphs[0].format.alignment).toBe('both');
});
-it.each(['', '', '', 'text', '', '', ''])('rejects malformed or unsupported active alignment: %s', async markup => {
+it.each(['', '', '', 'text', '', '', ''])('rejects malformed or unsupported active alignment: %s', async markup => {
await expect(DocxPackage.open(rewrite('', markup))).rejects.toThrow();
});
-it('rejects Arabic justification rather than silently substituting inter-word spacing for kashida', async () => {
- await expect(DocxPackage.open(rewrite('Direct both ', 'Direct both العربية '))).rejects.toThrow(/kashida/);
- expect(() => layout(simple('العربية words'))).toThrow(/kashida/);
- const doc = simple('One two'); for (const alignment of ['justify', 'distribute', 'lowKashida']) expect(() => doc.setParagraphFormat({ alignment })).toThrow();
+it('justifies Arabic by its word spaces instead of refusing it, without kashida', async () => {
+ // Kashida elongation is not drawn: Arabic lines are filled by widening their spaces, as Latin text.
+ const pkg = await DocxPackage.open(rewrite('Direct both ', 'Direct both العربية '));
+ expect(model(pkg).paragraphs.some(p => paragraphText(p).includes('العربية'))).toBe(true);
+ const arabic = 'هذا نص عربي طويل يلتف عبر عدة أسطر للتأكد من أن التخطيط يعمل بشكل صحيح في المحرر';
+ const result = layout(simple(arabic)), lines = result.lines;
+ expect(lines.length).toBeGreaterThan(1);
+ for (const line of lines.slice(0, -1)) { expect(line.justificationSpacing).toBeGreaterThan(0); expect(edge(line)).toBeCloseTo(312); }
+ expect(lines.at(-1).justificationSpacing).toBeUndefined();
+ expect(lines.flatMap(l => l.fragments).every(f => !f.text.includes('\u0640'))).toBe(true);
+ const typed = simple('Alpha beta gamma delta epsilon zeta eta theta iota'); typed.select(6); typed.replace('العربية ');
+ expect(() => layout(typed)).not.toThrow();
+ const doc = simple('One two'); for (const alignment of ['justify', 'thaiDistribute', 'lowKashida']) expect(() => doc.setParagraphFormat({ alignment })).toThrow();
expect(doc.undo()).toBe(false);
});
diff --git a/src/documents/tests/canvas-numbering.test.js b/src/documents/tests/canvas-numbering.test.js
index 6c6c9ac3b..b7e4f998f 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', 'thaiCounting');
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/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', () => {
diff --git a/src/documents/tests/canvas-strikethrough.test.js b/src/documents/tests/canvas-strikethrough.test.js
index c2f29adc2..b8ae298f8 100644
--- a/src/documents/tests/canvas-strikethrough.test.js
+++ b/src/documents/tests/canvas-strikethrough.test.js
@@ -98,7 +98,8 @@ const invalidStrikeDeclarations = [
['duplicate declaration', n => n.after(n.cloneNode(true))],
['nested content', n => n.append(node(n.ownerDocument, 'b'))],
['unknown attribute', n => n.setAttributeNS(W, 'w:other', '1')],
- ['conflicting form', n => n.after(node(n.ownerDocument, n.localName === 'strike' ? 'dstrike' : 'strike', '0'))],
+ // Both forms on would need a choice of form; an off declaration beside an on one does not (tests/strike-pairs.test.js).
+ ['conflicting form', n => n.after(node(n.ownerDocument, n.localName === 'strike' ? 'dstrike' : 'strike', '1'))],
];
it.each(['strike', 'dstrike'].flatMap(tag => invalidStrikeDeclarations.map(([label, change]) => [tag, label, change])))('rejects %s with %s', async (tag, label, change) => {
await expect(DocxPackage.open(revise('word/document.xml', xml => change(xml.getElementsByTagNameNS(W, tag)[0])))).rejects.toMatchObject({ name: 'UnsupportedDocumentError' });
diff --git a/src/documents/tests/canvas-styles.test.js b/src/documents/tests/canvas-styles.test.js
index 5db283591..7a05a5781 100644
--- a/src/documents/tests/canvas-styles.test.js
+++ b/src/documents/tests/canvas-styles.test.js
@@ -115,7 +115,7 @@ describe('paragraph styles across model, layout and DOCX', () => {
const pkg = await fixture(), doc = model(pkg), before = doc.snapshot();
expect(() => doc.editParagraphStyle('CanvasBody', { basedOn: 'CanvasSubheading' })).toThrow(/Cyclic/);
expect(() => doc.editParagraphStyle('CanvasBody', { run: { fontSize: 1638.5 } })).toThrow(/font size/);
- doc.styles.definitions.Unsupported = { id: 'Unsupported', name: 'Unsupported', type: 'paragraph', basedOn: 'Normal', properties: { alignment: 'distribute' }, run: {} };
+ doc.styles.definitions.Unsupported = { id: 'Unsupported', name: 'Unsupported', type: 'paragraph', basedOn: 'Normal', properties: { alignment: 'thaiDistribute' }, run: {} };
expect(doc.paragraphStyles.find(s => s.id === 'Unsupported').error).toMatch(/alignment/);
expect(() => doc.setParagraphStyle('Unsupported')).toThrow(/alignment/);
expect(doc.paragraphs).toEqual(before.paragraphs); expect(doc.history).toHaveLength(0);
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' });
});
diff --git a/src/documents/tests/captions.test.js b/src/documents/tests/captions.test.js
index 8a822919a..a3955450a 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,30 @@ 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('puts a cross-reference inserted inside another one after it', async () => {
+ const { d } = await setup();
+ d.select(0, 5); d.addBookmark('First'); d.select(10, 15); d.addBookmark('Second');
+ const [first, second] = referenceTargets(d).bookmarks;
+ d.select(d.text.length); d.replace(' see '); insertCrossReference(d, first);
+ const ref = bodyFieldRanges(d.paragraphs)[0];
+ d.select(ref.start + 2); insertCrossReference(d, second);
+ expect(d.text.split('\n').at(-1)).toBe('Conclusion see ChartChart');
+ expect(bodyFieldRanges(d.paragraphs).map(r => [d.text.slice(r.start, r.end), r.parentId ?? null])).toEqual([['Chart', null], ['Chart', null]]);
+ });
+
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' });
@@ -62,6 +86,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);
diff --git a/src/documents/tests/character-unit-indents.test.js b/src/documents/tests/character-unit-indents.test.js
new file mode 100644
index 000000000..6276c36e6
--- /dev/null
+++ b/src/documents/tests/character-unit-indents.test.js
@@ -0,0 +1,88 @@
+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, paragraphFormat } from '../src/engine/model.js';
+import { layoutDocument } from '../src/engine/layout.js';
+import { pageGeometry } from '../src/engine/page-settings.js';
+import { fontMetrics } from '../src/engine/font-metrics.js';
+
+// §17.3.1.12 ind: leftChars, rightChars, firstLineChars and hangingChars give an indent in hundredths
+// of a character unit and, when nonzero, override left, right, firstLine and hanging. Chinese and
+// Japanese documents (Word, WPS) indent nearly every paragraph this way ("首行缩进 2 字符"), so they
+// all opened read-only ("character-unit paragraph indents"). Word measures a character unit by the
+// East Asian font size of the Normal style; here 10.5 pt, so 200 hundredths are 420 twips.
+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 paragraph = (ind, text, style = '') => `${style ? `` : ''}${ind ? `` : ''}${text}`;
+const BODY = [
+ paragraph('w:firstLineChars="200" w:firstLine="420"', 'Word pair'),
+ paragraph('w:firstLineChars="200"', 'Characters only'),
+ paragraph('w:leftChars="100" w:left="210" w:hangingChars="50" w:hanging="105"', 'Left and hanging'),
+ paragraph('w:firstLineChars="200" w:firstLine="999"', 'Characters win'),
+ paragraph('', 'From style', 'Indented'),
+ paragraph('w:firstLineChars="0" w:firstLine="0"', 'Style cleared', 'Indented'),
+ paragraph('w:rightChars="300" w:right="0"', 'Right'),
+ paragraph('w:firstLineChars="200" w:firstLine="420"', 'Big heading', 'Big'),
+ paragraph('', 'Child style', 'Child'),
+].join('');
+function source() {
+ const styles = ``;
+ return zipSync(Object.fromEntries(Object.entries({
+ '[Content_Types].xml': ``,
+ '_rels/.rels': ``,
+ 'word/_rels/document.xml.rels': ``,
+ 'word/styles.xml': styles,
+ 'word/document.xml': `${BODY}`,
+ }).map(([name, xml]) => [name, strToU8(xml)])));
+}
+const model = pkg => new TextDocument(pkg.paragraphs, pkg.pageSettings, pkg.numbering, pkg.styles, pkg.tables, pkg.assets);
+const indents = doc => Object.fromEntries(doc.paragraphs.map(p => { const f = paragraphFormat(p, doc.numbering); return [p.runs.map(r => r.text).join(''), [f.indentLeft, f.indentRight, f.firstLine]]; }));
+const save = async (pkg, doc) => unzipSync(new Uint8Array(await pkg.export(doc.paragraphs, doc.pageSettings, doc.numbering, doc.styles, doc.tables, doc.assets).arrayBuffer()));
+const EXPECTED = { 'Word pair': [0, 0, 420], 'Characters only': [0, 0, 420], 'Left and hanging': [210, 0, -105], 'Characters win': [0, 0, 420], 'From style': [0, 0, 420], 'Style cleared': [0, 0, 0], Right: [0, 630, 0], 'Big heading': [0, 0, 420], 'Child style': [0, 0, 420] };
+
+it('opens character-unit indents for editing and lays the text out at their size', async () => {
+ const pkg = await DocxPackage.open(source());
+ expect(pkg.readOnly).toBeFalsy();
+ const doc = model(pkg);
+ expect(indents(doc)).toEqual(EXPECTED);
+ const measure = (text, style) => ({ ...fontMetrics(style), width: [...text].length * style.fontSize / 2 });
+ const lines = layoutDocument(doc.paragraphs, measure, pageGeometry(doc.pageSettings), doc.numbering, doc.tables).lines;
+ const left = text => lines.find(l => doc.text.slice(l.start, l.end).startsWith(text)).fragments[0].x;
+ // 420 twips are 28 CSS pixels.
+ expect(left('Word pair') - left('Style cleared')).toBeCloseTo(28, 5);
+ expect(left('Characters win') - left('Style cleared')).toBeCloseTo(28, 5);
+});
+
+it('keeps untouched character-unit attributes and writes an edited indent that Word reads the same way', async () => {
+ const pkg = await DocxPackage.open(source()), doc = model(pkg);
+ doc.select(doc.text.indexOf('Right') + 5); doc.replace(' edge');
+ let xml = strFromU8((await save(pkg, doc))['word/document.xml']);
+ for (const ind of ['w:firstLineChars="200" w:firstLine="420"', 'w:firstLineChars="200"/>', 'w:leftChars="100" w:left="210" w:hangingChars="50" w:hanging="105"', 'w:rightChars="300" w:right="0"']) expect(xml).toContain(ind);
+ // A changed indent is written in twips, with its character-unit form set to zero so it no longer overrides them.
+ doc.select(doc.text.indexOf('Word pair')); doc.setParagraphFormat({ firstLine: 720 });
+ doc.select(doc.text.indexOf('From style')); doc.setParagraphFormat({ indentLeft: 360 });
+ doc.select(doc.text.indexOf('Left and hanging')); doc.setParagraphFormat({ firstLine: 0 });
+ const parts = await save(pkg, doc); xml = strFromU8(parts['word/document.xml']);
+ expect(xml).toMatch(/|/);
+ const reopened = model(await DocxPackage.open(zipSync(parts)));
+ const { Right, ...rest } = EXPECTED;
+ expect(indents(reopened)).toEqual({ ...rest, 'Word pair': [0, 0, 720], 'From style': [360, 0, 420], 'Left and hanging': [210, 0, 0], 'Right edge': Right });
+ // A style that inherits its character-unit indent gets a zero character-unit form beside its own twips.
+ doc.editParagraphStyle('Child', { paragraph: { firstLine: 120 } });
+ const edited = await save(pkg, doc);
+ expect(strFromU8(edited['word/styles.xml'])).toMatch(/w:styleId="Child">.*?]*w:firstLine="120")(?=[^>]*w:firstLineChars="0")[^>]*\/>/);
+ expect(indents(model(await DocxPackage.open(zipSync(edited))))['Child style']).toEqual([0, 0, 120]);
+ // Editing a style's indent does the same in the style.
+ doc.editParagraphStyle('Indented', { paragraph: { firstLine: 240 } });
+ const styles = strFromU8((await save(pkg, doc))['word/styles.xml']);
+ expect(styles).toMatch(/w:styleId="Indented">.*]*w:firstLine="240"/);
+ expect(styles).toMatch(/w:styleId="Indented">.*]*w:firstLineChars="0"/);
+ expect(indents(doc)['Style cleared']).toEqual([0, 0, 0]);
+ expect(indents(doc)['From style']).toEqual([360, 0, 240]);
+ expect(indents(doc)['Child style']).toEqual([0, 0, 120]);
+});
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);
+});
diff --git a/src/documents/tests/clipboard-fragment.test.js b/src/documents/tests/clipboard-fragment.test.js
index 002feb012..f0dbe6f68 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
- One
');
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);
});
@@ -92,3 +92,25 @@ 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([]);
+});
+
+it('keeps a table row on one line when its cells hold paragraphs, as Word, LibreOffice and Google Docs write them', () => {
+ expect(texts('')).toEqual(['Name\tQty', 'Apples\t3']);
+ expect(texts('After
')).toEqual(['Item\tTwo lines', 'After']);
+ expect(texts('')).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]]);
+});
diff --git a/src/documents/tests/clipboard-objects.test.js b/src/documents/tests/clipboard-objects.test.js
index 07fb4ee87..bc69f0483 100644
--- a/src/documents/tests/clipboard-objects.test.js
+++ b/src/documents/tests/clipboard-objects.test.js
@@ -20,6 +20,17 @@ it('copies fields as their results, equations as their text and drops bare objec
expect(note.clipboardText((run, at) => run.noteMark && at === 5 ? '1' : '?')).toBe('Claim1');
});
+it('copies a table in a selection as one line per row with a tab between cells', () => {
+ const d = new TextDocument([paragraph('Before')]);
+ d.select(6); d.insertTable(2, 3);
+ for (const [i, text] of ['a1', 'b1', 'c1', 'a2', 'b2', 'c2'].entries()) { if (i) d.moveCell(1); d.replace(text); }
+ // A second paragraph in a cell stays on its own line, as in other word processors.
+ d.select(d.text.indexOf('b2') + 2); d.insertParagraph(); d.replace('more');
+ d.select(d.text.length); d.replace('After');
+ d.select(0, d.text.length);
+ expect(d.clipboardText()).toBe('Before\na1\tb1\tc1\na2\tb2\nmore\tc2\nAfter');
+});
+
let editor;
afterEach(() => { editor?.destroy(); document.body.replaceChildren(); vi.restoreAllMocks(); });
diff --git a/src/documents/tests/collab-binding.test.js b/src/documents/tests/collab-binding.test.js
index cc559317d..cfe3c2703 100644
--- a/src/documents/tests/collab-binding.test.js
+++ b/src/documents/tests/collab-binding.test.js
@@ -199,6 +199,42 @@ 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('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'));
diff --git a/src/documents/tests/collab-outbox-account.test.js b/src/documents/tests/collab-outbox-account.test.js
new file mode 100644
index 000000000..f347e0306
--- /dev/null
+++ b/src/documents/tests/collab-outbox-account.test.js
@@ -0,0 +1,43 @@
+import { it, expect, beforeEach, afterEach } from 'vitest';
+
+/** Just enough of IndexedDB for the outbox: one store, put/getAll/delete, completing after the calls. */
+function memoryIndexedDB() {
+ const rows = new Map();
+ const store = {
+ put: item => { rows.set(item.key, structuredClone(item)); return {}; },
+ delete: key => { rows.delete(key); return {}; },
+ getAll: () => ({ result: [...rows.values()].map(r => structuredClone(r)) }),
+ };
+ const db = { transaction: () => { const tx = { objectStore: () => store }; setTimeout(() => tx.oncomplete?.(), 0); return tx; } };
+ return { rows, open: () => { const request = { result: db }; setTimeout(() => { request.onupgradeneeded?.(); request.onsuccess?.(); }, 0); request.result.createObjectStore = () => store; return request; } };
+}
+
+let idb;
+beforeEach(() => { idb = memoryIndexedDB(); globalThis.indexedDB = idb; });
+afterEach(() => { delete globalThis.indexedDB; });
+
+const { Outbox, CollabClient } = await import('../src/collab/client.js');
+
+it('gives back only the changes this account left undelivered in a room, keeping the others\' for them', async () => {
+ const ann = new Outbox('room1', 's1', 'ann-uuid');
+ await ann.add(new Uint8Array([1, 2, 3]));
+ await new Outbox('room2', 's2', 'ann-uuid').add(new Uint8Array([9]));
+ // Someone else signs in on this computer and opens the same shared document.
+ const bob = new Outbox('room1', 's3', 'bob-uuid');
+ expect(await bob.restore()).toEqual([]);
+ // Ann's change waits for her, in its room only.
+ const again = await new Outbox('room1', 's4', 'ann-uuid').restore();
+ expect(again.map(item => [...item.update])).toEqual([[1, 2, 3]]);
+ expect(idb.rows.size).toBe(2);
+});
+
+it('leaves changes kept before they named their account alone, rather than sending them as whoever opens the room', async () => {
+ idb.rows.set('legacy', { key: 'legacy', room: 'room1', at: 1, seq: 0, update: new Uint8Array([7]) });
+ expect(await new Outbox('room1', 's1', 'bob-uuid').restore()).toEqual([]);
+ expect(idb.rows.has('legacy')).toBe(true);
+});
+
+it('names the person\'s account on what a client keeps', () => {
+ const client = new CollabClient({ transport: {}, user: { id: 'ann-uuid', name: 'Ann' }, room: 'room1' });
+ expect(client.outbox.account).toBe('ann-uuid');
+});
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();
+});
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();
+ });
});
diff --git a/src/documents/tests/collab-shared-doc.test.js b/src/documents/tests/collab-shared-doc.test.js
index 03a28191f..36f8a817a 100644
--- a/src/documents/tests/collab-shared-doc.test.js
+++ b/src/documents/tests/collab-shared-doc.test.js
@@ -205,3 +205,110 @@ describe('shared document mapping', () => {
expect(readBody(ydoc.getText('body')).map(p => p.runs.map(r => r.text).join(''))).toEqual(['loose']);
});
});
+
+describe('typing in a table while someone else changes its structure', () => {
+ // A 3×2 table after "Intro": cells in reading order are [r1c1, r1c2, r2c1, r2c2, r3c1, r3c2].
+ const start = () => {
+ const seed = new TextDocument([paragraph('Intro')]);
+ seed.select(5); seed.insertTable(3, 2);
+ const origin = createSharedDoc(stateOf(seed));
+ const update = Y.encodeStateAsUpdate(origin);
+ // Fixed client ids: Yjs orders insertions made at one place at the same moment by them.
+ return ['ann', 'bob'].map((name, i) => { const d = new Y.Doc(); d.clientID = 1000 + i; Y.applyUpdate(d, update); return new Peer(name, d); });
+ };
+ const cells = m => (m.tables[0]?.rows || []).flatMap(r => r.cells.map(c => c.id));
+ const at = (m, cellId) => { m.select(m.paragraphOffset(m.paragraphs.findIndex(p => p.cellId === cellId))); };
+ const holder = (m, text) => m.paragraphs.find(p => p.runs.some(r => r.text.includes(text)))?.cellId;
+ const cases = {
+ 'inserts a row': (m, ids) => { at(m, ids[0]); m.insertTableRow(); },
+ 'deletes a row': (m, ids) => { at(m, ids[0]); m.deleteTablePart('row'); },
+ 'inserts a column': (m, ids) => { at(m, ids[0]); m.insertTableColumn(); },
+ 'deletes a column': (m, ids) => { at(m, ids[0]); m.deleteTablePart('column'); },
+ 'merges cells': (m, ids) => { const a = m.paragraphOffset(m.paragraphs.findIndex(p => p.cellId === ids[0])), b = m.paragraphOffset(m.paragraphs.findIndex(p => p.cellId === ids[1])); m.select(a, b); m.mergeCells(); },
+ };
+ // The cell typed in: not one a new row or column goes right before (see below).
+ const targets = { 'inserts a row': 3, 'deletes a row': 3, 'inserts a column': 2, 'deletes a column': 3, 'merges cells': 3 };
+ for (const [name, change] of Object.entries(cases)) {
+ it(`keeps text typed in another cell in that cell when someone ${name}`, () => {
+ const [ann, bob] = start();
+ const ids = cells(ann.model), target = ids[targets[name]];
+ ann.edit(m => change(m, ids));
+ bob.edit(m => { at(m, target); m.replace('B2'); });
+ deliverAll([ann, bob]);
+ for (const peer of [ann, bob]) {
+ expect(holder(peer.model, 'B2'), `${peer.name}`).toBe(target);
+ // The table reads back whole: every cell has its paragraphs, in reading order.
+ expect(() => modelFrom(readState(peer.ydoc))).not.toThrow();
+ }
+ expect(bob.ydoc.getText('body').toString()).toBe(ann.ydoc.getText('body').toString());
+ expect(JSON.stringify(visible(bob.model))).toBe(JSON.stringify(visible(ann.model)));
+ });
+ }
+ it('keeps text typed in a cell someone else merges into the cell beside it, in the merged cell', () => {
+ const [ann, bob] = start();
+ const ids = cells(ann.model);
+ ann.edit(m => cases['merges cells'](m, ids));
+ bob.edit(m => { at(m, ids[1]); m.replace('B1'); });
+ deliverAll([ann, bob]);
+ for (const peer of [ann, bob]) {
+ expect(() => modelFrom(readState(peer.ydoc))).not.toThrow();
+ expect(holder(peer.model, 'B1')).toBe(ids[0]);
+ }
+ expect(bob.ydoc.getText('body').toString()).toBe(ann.ydoc.getText('body').toString());
+ });
+ it('keeps text typed in a row someone else deletes, and the table whole', () => {
+ const [ann, bob] = start();
+ const ids = cells(ann.model);
+ ann.edit(m => { at(m, ids[2]); m.deleteTablePart('row'); });
+ bob.edit(m => { at(m, ids[3]); m.replace('Lost?'); });
+ deliverAll([ann, bob]);
+ for (const peer of [ann, bob]) {
+ expect(() => modelFrom(readState(peer.ydoc))).not.toThrow();
+ expect(peer.model.tables[0].rows).toHaveLength(2);
+ // No paragraph refers to a cell that is gone.
+ const live = new Set(cells(peer.model));
+ expect(peer.model.paragraphs.filter(p => p.cellId && !live.has(p.cellId))).toEqual([]);
+ // What was typed there is kept, in the cell after it.
+ expect(holder(peer.model, 'Lost?')).toBe(ids[4]);
+ }
+ expect(bob.ydoc.getText('body').toString()).toBe(ann.ydoc.getText('body').toString());
+ });
+
+ it('stays whole and keeps everyone\'s typing under random row and column changes by one person', () => {
+ // Typing goes on after text already in a cell. (Typing into an empty cell right where someone else inserts a row
+ // or column at the same moment can still land in a new cell: the two insertions share one place in the text.)
+ const filled = () => {
+ const seed = new TextDocument([paragraph('Intro')]);
+ seed.select(5); seed.insertTable(3, 2);
+ seed.tables[0].rows.flatMap(r => r.cells).forEach((c, i) => { seed.selectCell(c.id); seed.replace(`c${i}`); });
+ const update = Y.encodeStateAsUpdate(createSharedDoc(stateOf(seed)));
+ return ['ann', 'bob'].map((name, i) => { const d = new Y.Doc(); d.clientID = 1000 + i; Y.applyUpdate(d, update); return new Peer(name, d); });
+ };
+ const end = (m, cellId) => { const i = m.paragraphs.findLastIndex(p => p.cellId === cellId); m.select(m.paragraphOffset(i) + m.paragraphs[i].runs.reduce((n, r) => n + r.text.length, 0)); };
+ for (let seed = 1; seed <= 40; seed++) {
+ const rnd = random(seed);
+ const [ann, bob] = filled();
+ const typed = [];
+ for (let step = 0; step < 12; step++) {
+ const ids = cells(ann.model);
+ if (!ids.length) break;
+ const pick = () => ids[Math.floor(rnd() * ids.length)];
+ const kind = ['inserts a row', 'deletes a row', 'inserts a column', 'deletes a column'][Math.floor(rnd() * 4)];
+ try { ann.edit(m => cases[kind](m, [pick(), pick()])); } catch { /* a change the table refuses (the last row, say) */ }
+ const own = cells(bob.model).filter(id => bob.model.paragraphs.some(p => p.cellId === id && p.runs.some(r => r.text)));
+ if (!own.length) continue;
+ const cell = own[Math.floor(rnd() * own.length)], word = `w${seed}x${step}z`;
+ bob.edit(m => { end(m, cell); m.replace(rnd() < 0.2 ? `${word}\n${word}b` : word); });
+ typed.push([word, cell]);
+ if (rnd() < 0.6) deliverAll([ann, bob]);
+ }
+ deliverAll([ann, bob]); deliverAll([ann, bob]);
+ expect(bob.ydoc.getText('body').toString(), `seed ${seed}`).toBe(ann.ydoc.getText('body').toString());
+ const model = modelFrom(readState(ann.ydoc));
+ const live = new Set(cells(model));
+ expect(model.paragraphs.filter(p => p.cellId && !live.has(p.cellId)), `seed ${seed}`).toEqual([]);
+ // What was typed in a cell that is still there is in that cell.
+ for (const [word, cell] of typed) if (live.has(cell)) expect(holder(model, word), `seed ${seed} ${word}`).toBe(cell);
+ }
+ });
+});
diff --git a/src/documents/tests/comment-column.test.js b/src/documents/tests/comment-column.test.js
new file mode 100644
index 000000000..96eb3134f
--- /dev/null
+++ b/src/documents/tests/comment-column.test.js
@@ -0,0 +1,170 @@
+// @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';
+import { revisionView } from '../src/engine/revisions.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');
+});
+
+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);
+});
+
+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('');
+});
+
+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();
+});
+
+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);
+});
+
+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'; }
+});
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);
diff --git a/src/documents/tests/comment-styles.test.js b/src/documents/tests/comment-styles.test.js
new file mode 100644
index 000000000..30197ce63
--- /dev/null
+++ b/src/documents/tests/comment-styles.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 { docxWithParagraphs } from './helpers/docx.js';
+
+// A new comment's paragraphs use the paragraph style CommentText and its reference marks the character
+// style CommentReference, as Word writes them (its built-in "annotation text" and "annotation
+// reference", §17.7.4.17 style identifiers). A document that had no comments usually lacks both,
+// so its comments were saved with references to styles the file does not define. The styles part
+// now gets Word's definitions of the two when they are missing.
+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 STYLES = ``;
+function withStyles(extra = '') {
+ const parts = unzipSync(docxWithParagraphs([{ text: 'Commented text' }]));
+ parts['word/styles.xml'] = strToU8(`${STYLES}${extra}`);
+ parts['word/_rels/document.xml.rels'] = strToU8(``);
+ parts['[Content_Types].xml'] = strToU8(strFromU8(parts['[Content_Types].xml']).replace('', ''));
+ return zipSync(parts);
+}
+async function comment(bytes) {
+ const pkg = await DocxPackage.open(bytes);
+ const doc = new TextDocument(pkg.paragraphs, pkg.pageSettings, pkg.numbering, pkg.styles, pkg.tables, pkg.assets, { comments: pkg.comments });
+ doc.select(0, 9); doc.addComment({ author: 'Ada', text: 'Check this' });
+ return unzipSync(new Uint8Array(await pkg.export(doc.paragraphs, doc.pageSettings, doc.numbering, doc.styles, doc.tables, doc.assets, undefined, doc.comments).arrayBuffer()));
+}
+const styleIds = xml => [...new DOMParser().parseFromString(xml, 'application/xml').getElementsByTagNameNS(W, 'style')].map(s => `${s.getAttributeNS(W, 'type')}:${s.getAttributeNS(W, 'styleId')}`);
+
+it('adds the comment styles a new comment uses when the document lacks them', async () => {
+ const parts = await comment(withStyles());
+ expect(strFromU8(parts['word/comments.xml'])).toContain('');
+ const styles = strFromU8(parts['word/styles.xml']);
+ expect(styleIds(styles)).toEqual(['paragraph:Normal', 'character:DefaultParagraphFont', 'paragraph:CommentText', 'character:CommentReference']);
+ expect(styles).toContain('');
+ expect(styles).toContain('');
+ const reopened = await DocxPackage.open(zipSync(parts));
+ expect(reopened.readOnly).toBeFalsy();
+ expect(reopened.styles.definitions.CommentText.basedOn).toBe('Normal');
+ expect(reopened.comments.map(c => c.text)).toEqual(['Check this']);
+});
+
+it('leaves comment styles the document already defines as they are', async () => {
+ const own = '';
+ const parts = await comment(withStyles(own));
+ expect(strFromU8(parts['word/styles.xml'])).toBe(strFromU8(unzipSync(withStyles(own))['word/styles.xml']));
+});
diff --git a/src/documents/tests/compare-documents.test.js b/src/documents/tests/compare-documents.test.js
index 9fcb28e4e..5ecda5d24 100644
--- a/src/documents/tests/compare-documents.test.js
+++ b/src/documents/tests/compare-documents.test.js
@@ -15,7 +15,7 @@ const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
function docx(body, { creator } = {}) {
const files = {
'[Content_Types].xml': '' + (creator ? '' : '') + '',
- '_rels/.rels': '',
+ '_rels/.rels': '' + (creator ? '' : '') + '',
'word/document.xml': `${body}`,
...(creator ? { 'docProps/core.xml': `${creator}` } : {}),
};
@@ -211,3 +211,14 @@ it('names the revised document\'s author from its core properties', async () =>
expect(documentAuthor(parts)).toBe('Ada Lovelace');
expect(documentAuthor({})).toBeNull();
});
+
+// Core properties are the part the package relationship names (ECMA-376 Part 2 §11), which
+// System.IO.Packaging stores as package/services/metadata/core-properties/.psmdcp.
+it('names the author from a core properties part stored under another name', async () => {
+ const parts = unzipSync(docx(p('x'), { creator: 'Ada Lovelace' }));
+ const path = 'package/services/metadata/core-properties/7c1a.psmdcp';
+ parts[path] = strToU8(strFromU8(parts['docProps/core.xml']).replace('', 'Grace Hopper'));
+ delete parts['docProps/core.xml'];
+ parts['_rels/.rels'] = strToU8(strFromU8(parts['_rels/.rels']).replace('Target="docProps/core.xml"', `Target="/${path}"`));
+ expect(documentAuthor(parts)).toBe('Grace Hopper');
+});
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');
+});
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..339730db8
--- /dev/null
+++ b/src/documents/tests/dash-line-breaking.test.js
@@ -0,0 +1,39 @@
+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 });
+});
+
+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']);
+});
diff --git a/src/documents/tests/diagram-builder.test.js b/src/documents/tests/diagram-builder.test.js
index ef59f34ab..d19382aa5 100644
--- a/src/documents/tests/diagram-builder.test.js
+++ b/src/documents/tests/diagram-builder.test.js
@@ -2,7 +2,7 @@ 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 { DIAGRAM_LAYOUTS, DIAGRAM_COLORS, DEFAULT_OUTLINE, parseOutline, outlineText, diagramShapes, diagramDrawingXml, parseDiagramDescription } from '../src/engine/diagram-builder.js';
+import { DIAGRAM_LAYOUTS, DIAGRAM_COLORS, DEFAULT_OUTLINE, parseOutline, outlineText, diagramShapes, diagramDrawingXml, parseDiagramDescription, hiddenDiagramItems } from '../src/engine/diagram-builder.js';
import { readDrawingFragment } from '../src/engine/inserted-drawings.js';
let dom;
@@ -81,3 +81,15 @@ it('says how much text a diagram can hold when an outline is too long for it', (
const items = parseOutline(Array.from({ length: 40 }, (_, i) => `Milestone ${i}: a fairly long description of what happens in this phase`).join('\n'));
expect(() => diagramDrawingXml({ layout: DIAGRAM_LAYOUTS[0].id, items, id: 1 })).toThrow(/too much text: it can hold about \d+ characters/);
});
+
+it('says which items a layout does not show', () => {
+ const deep = parseOutline('A\n B\n C\nD\n E');
+ expect(hiddenDiagramItems('verticalList', deep)).toEqual(['C']);
+ expect(hiddenDiagramItems('basicCycle', deep)).toEqual(['B', 'C', 'E']);
+ expect(hiddenDiagramItems('orgChart', parseOutline('CEO\n VP\n Dir\n Mgr'))).toEqual(['Mgr']);
+ expect(hiddenDiagramItems('venn', parseOutline('a\nb\nc\nd\ne'))).toEqual(['e']);
+ expect(hiddenDiagramItems('matrix', parseOutline('a\nb\nc\nd'))).toEqual([]);
+ // Items with the same text are counted one by one.
+ expect(hiddenDiagramItems('basicCycle', parseOutline('Plan\n Plan\nDo'))).toEqual(['Plan']);
+ for (const layout of DIAGRAM_LAYOUTS) expect(hiddenDiagramItems(layout.id, parseOutline(DEFAULT_OUTLINE)), layout.id).toEqual(expect.any(Array));
+});
diff --git a/src/documents/tests/distributed-alignment.test.js b/src/documents/tests/distributed-alignment.test.js
new file mode 100644
index 000000000..6c480ed49
--- /dev/null
+++ b/src/documents/tests/distributed-alignment.test.js
@@ -0,0 +1,49 @@
+import { beforeAll, afterAll, it, expect } from 'vitest';
+import { JSDOM } from 'jsdom';
+import { readFileSync } from 'node:fs';
+import { unzipSync, zipSync, strToU8, strFromU8 } from 'fflate';
+import { DocxPackage } from '../src/engine/docx.js';
+import { TextDocument, paragraph, paragraphText } from '../src/engine/model.js';
+import { layoutDocument, caretRect, hitTest } from '../src/engine/layout.js';
+import { pageGeometry } from '../src/engine/page-settings.js';
+import { canvasWordSpacing, canvasLetterSpacing } from '../src/engine/canvas-text.js';
+
+let dom;
+beforeAll(() => { dom = new JSDOM(''); globalThis.DOMParser = dom.window.DOMParser; globalThis.XMLSerializer = dom.window.XMLSerializer; });
+afterAll(() => { dom.window.close(); delete globalThis.DOMParser; delete globalThis.XMLSerializer; });
+// Canvas metrics: letter spacing follows every character, word spacing every space.
+const measure = (text, style) => ({ width: [...text].length * (8 + canvasLetterSpacing(style)) + (text.match(/[ ]/g)?.length || 0) * canvasWordSpacing(style), ascent: 12, descent: 4 });
+// A 200 px writing width.
+const layout = (text, format = {}) => layoutDocument([paragraph(text, { format: { spaceAfter: 0, alignment: 'distribute', ...format } })], measure, { width: 220, height: 2000, margin: 10 });
+const edge = line => Math.max(...line.stops.map(s => s.x)) - (line.hangingRight ? 0 : line.ignoredTrailingWidth);
+
+it('distributes every line, the last one too, by equal gaps between all characters (§17.18.44 distribute)', () => {
+ const text = 'Alpha beta gamma delta epsilon zeta eta theta', result = layout(text);
+ expect(result.lines.length).toBeGreaterThan(1);
+ for (const line of result.lines) expect(edge(line)).toBeCloseTo(210, 5);
+ // The last line, "epsilon zeta eta theta" (22 characters, 21 gaps), spreads across the whole measure.
+ const last = result.lines.at(-1), xs = last.stops.map(s => s.x).sort((a, b) => a - b);
+ expect(text.slice(last.start, last.end)).toBe('epsilon zeta eta theta');
+ const steps = xs.slice(1).map((x, i) => x - xs[i]);
+ for (const step of steps.slice(0, -1)) expect(step).toBeCloseTo(steps[0], 5);
+ expect(steps[0]).toBeCloseTo(8 + (200 - 22 * 8) / 21, 5);
+ // Carets and pointer hits follow the spread characters.
+ const caret = caretRect(result, last.start + 2);
+ expect(hitTest(result, caret.page, caret.x + 1, caret.y + caret.height / 2)).toBe(last.start + 2);
+ // A single word of one paragraph is spread as well; an empty paragraph keeps its caret.
+ expect(edge(layout('Title').lines[0])).toBeCloseTo(210, 5);
+ expect(layout('').lines).toHaveLength(1);
+});
+
+it('opens and keeps distributed paragraphs editable, and sets distributed alignment', async () => {
+ const source = readFileSync('public/fixtures/justification.docx'), parts = unzipSync(source);
+ parts['word/document.xml'] = strToU8(strFromU8(parts['word/document.xml']).replace('', ''));
+ const pkg = await DocxPackage.open(zipSync(parts));
+ const doc = new TextDocument(pkg.paragraphs, pkg.pageSettings, pkg.numbering, pkg.styles, pkg.tables);
+ expect(doc.paragraphs.some(p => p.format.alignment === 'distribute')).toBe(true);
+ expect(() => layoutDocument(doc.paragraphs, measure, pageGeometry(doc.pageSettings), doc.numbering, doc.tables)).not.toThrow();
+ const fresh = new TextDocument([paragraph('One two')], { width: 5400, left: 720, right: 720 });
+ fresh.setParagraphFormat({ alignment: 'distribute' });
+ expect(fresh.paragraphs[0].format.alignment).toBe('distribute');
+ expect(paragraphText(fresh.paragraphs[0])).toBe('One two');
+});
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;
diff --git a/src/documents/tests/document-export.test.js b/src/documents/tests/document-export.test.js
index e0dc8908a..92a1ae97d 100644
--- a/src/documents/tests/document-export.test.js
+++ b/src/documents/tests/document-export.test.js
@@ -351,3 +351,68 @@ 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
');
+ });
+});
+
+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('- a
- b
- c
- 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('- a3
');
+ const markdown = documentMarkdown(d);
+ 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 |');
+ });
+});
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/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();
+});
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');
+});
diff --git a/src/documents/tests/field-results.test.js b/src/documents/tests/field-results.test.js
index 32f13c7c3..e2725b40e 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' });
});
});
@@ -159,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');
+});
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]);
+});
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'));
+});
diff --git a/src/documents/tests/fixtures/numbering-definition-recovery/format.docx b/src/documents/tests/fixtures/numbering-definition-recovery/format.docx
index 5d9a299b3..5df71f7a3 100644
Binary files a/src/documents/tests/fixtures/numbering-definition-recovery/format.docx and b/src/documents/tests/fixtures/numbering-definition-recovery/format.docx differ
diff --git a/src/documents/tests/fixtures/numbering-definition-recovery/inherited.docx b/src/documents/tests/fixtures/numbering-definition-recovery/inherited.docx
index c59938239..2c16ddd73 100644
Binary files a/src/documents/tests/fixtures/numbering-definition-recovery/inherited.docx and b/src/documents/tests/fixtures/numbering-definition-recovery/inherited.docx differ
diff --git a/src/documents/tests/fixtures/numbering-definition-recovery/override.docx b/src/documents/tests/fixtures/numbering-definition-recovery/override.docx
index d5b1487b3..e1a575f29 100644
Binary files a/src/documents/tests/fixtures/numbering-definition-recovery/override.docx and b/src/documents/tests/fixtures/numbering-definition-recovery/override.docx differ
diff --git a/src/documents/tests/fixtures/page-number-parts/README.md b/src/documents/tests/fixtures/page-number-parts/README.md
new file mode 100644
index 000000000..7fbe402f3
--- /dev/null
+++ b/src/documents/tests/fixtures/page-number-parts/README.md
@@ -0,0 +1,13 @@
+# Page-number building blocks in headers and footers
+
+`build.mjs` writes `page-number-parts.docx` from hand-authored WordprocessingML (it does not use the
+editor's writer). Its three stories follow the structure word processors save for Insert > Page Number:
+
+- `header1.xml`: Top of Page, one block `w:sdt` with `w:docPartObj`/`w:docPartGallery w:val="Page Numbers (Top of Page)"`
+ around a right-aligned paragraph holding a complex `PAGE` field, followed by an ordinary empty paragraph.
+- `footer1.xml`: Bottom of Page, the same wrapper with the "Page Numbers (Bottom of Page)" gallery and an
+ `w:sdtEndPr`, centred, with a `w:fldSimple` `PAGE`.
+- `footer2.xml` (first page): "Page X of Y", a Bottom of Page wrapper nesting a Top of Page wrapper, as the
+ "Bold Numbers" design is saved, with `PAGE` and `NUMPAGES` fields.
+
+Run `node tests/fixtures/page-number-parts/build.mjs` to regenerate the file.
diff --git a/src/documents/tests/fixtures/page-number-parts/build.mjs b/src/documents/tests/fixtures/page-number-parts/build.mjs
new file mode 100644
index 000000000..1a86e24a2
--- /dev/null
+++ b/src/documents/tests/fixtures/page-number-parts/build.mjs
@@ -0,0 +1,24 @@
+// Writes page-number-parts.docx from hand-written WordprocessingML (see README.md).
+import { zipSync, strToU8 } from 'fflate';
+import { writeFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+const NS = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
+const head = '\n';
+const complex = (code, cached) => ` ${code} ${cached}`;
+const part = (gallery, id, inner, end = '') => `${end}${inner}`;
+const header = `${part('Page Numbers (Top of Page)', '-1318548329', `${complex('PAGE \\* MERGEFORMAT', '1')}`)}`;
+const footer = `${part('Page Numbers (Bottom of Page)', '-2034520717', `1`, '')}`;
+const pageOf = `Page ${complex('PAGE', '1')} of ${complex('NUMPAGES', '1')}`;
+const firstFooter = `${part('Page Numbers (Bottom of Page)', '98404467', part('Page Numbers (Top of Page)', '1728636285', pageOf))}`;
+const body = `${Array.from({ length: 3 }, (_, i) => `${i ? '' : ''}Body page ${i + 1}`).join('')}`;
+const styles = ``;
+const type = name => `application/vnd.openxmlformats-officedocument.wordprocessingml.${name}+xml`;
+const files = {
+ '[Content_Types].xml': ``,
+ '_rels/.rels': '',
+ 'word/_rels/document.xml.rels': '',
+ 'word/document.xml': body, 'word/styles.xml': styles, 'word/header1.xml': header, 'word/footer1.xml': footer, 'word/footer2.xml': firstFooter,
+};
+const out = fileURLToPath(new URL('../../../public/fixtures/page-number-parts.docx', import.meta.url));
+writeFileSync(out, zipSync(Object.fromEntries(Object.entries(files).map(([name, text]) => [name, strToU8(head + text)])), { mtime: new Date('2026-10-27T00:00:00Z') }));
diff --git a/src/documents/tests/fixtures/vml-watermarks/README.md b/src/documents/tests/fixtures/vml-watermarks/README.md
new file mode 100644
index 000000000..444b6f6c1
--- /dev/null
+++ b/src/documents/tests/fixtures/vml-watermarks/README.md
@@ -0,0 +1,8 @@
+# Word VML watermarks
+
+`watermarks.docx` is generated by `make-fixture.mjs` from the XML in `fixture.js`, written for this
+project in the shape Word gives a document with Design > Watermark (ECMA-376 Part 4 §14.1 VML,
+Part 1 §17.3.3.19 `w:pict`): the default header holds a text watermark (`PowerPlusWaterMarkObject`,
+a `v:textpath` "CONFIDENTIAL" in silver at 50 % opacity, turned 315°, centred on the margins) beside
+its own text; the first-page header holds a picture watermark (`WordPictureWatermark`, a 40 × 20 PNG
+washed out with `gain`/`blacklevel`). No other editor produced or saved the file.
diff --git a/src/documents/tests/fixtures/vml-watermarks/fixture.js b/src/documents/tests/fixtures/vml-watermarks/fixture.js
new file mode 100644
index 000000000..77eea6aad
--- /dev/null
+++ b/src/documents/tests/fixtures/vml-watermarks/fixture.js
@@ -0,0 +1,25 @@
+import { zipSync, strToU8 } from 'fflate';
+
+// An independently written package in the shape Word gives a document with Design > Watermark: the
+// default header holds a text watermark (a WordArt text path) beside its own text, the first-page
+// header a picture watermark (washed-out v:imagedata). Generated here, not by the editor's writer.
+const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main', R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
+export const NS = `xmlns:w="${W}" xmlns:r="${R}" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w10="urn:schemas-microsoft-com:office:word"`;
+const SHAPETYPE_TEXT = '';
+export const TEXT_PICT = `${SHAPETYPE_TEXT}`;
+export const PICTURE_PICT = '';
+// A 40 × 20 PNG: red on its left half, blue on its right.
+export const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAACgAAAAUCAIAAABwJOjsAAAAJ0lEQVR4nGO4IydHNqJA6x2GUYtHLR61eNTiUYtHLR61eNTikWMxALnMay7BH6g1AAAAAElFTkSuQmCC', 'base64'));
+export function watermarkFixture() {
+ const header = (body, extra = '') => `\n${body}${extra}`;
+ return zipSync({
+ '[Content_Types].xml': strToU8('\n'),
+ '_rels/.rels': strToU8('\n'),
+ 'word/_rels/document.xml.rels': strToU8('\n'),
+ 'word/_rels/header2.xml.rels': strToU8('\n'),
+ 'word/media/watermark.png': PNG,
+ 'word/document.xml': strToU8(`\nBody text.`),
+ 'word/header1.xml': strToU8(header(TEXT_PICT, 'Acme internal')),
+ 'word/header2.xml': strToU8(header(PICTURE_PICT)),
+ });
+}
diff --git a/src/documents/tests/fixtures/vml-watermarks/make-fixture.mjs b/src/documents/tests/fixtures/vml-watermarks/make-fixture.mjs
new file mode 100644
index 000000000..87f35b53d
--- /dev/null
+++ b/src/documents/tests/fixtures/vml-watermarks/make-fixture.mjs
@@ -0,0 +1,4 @@
+// Writes watermarks.docx from fixture.js: node tests/fixtures/vml-watermarks/make-fixture.mjs
+import { writeFileSync } from 'node:fs';
+import { watermarkFixture } from './fixture.js';
+writeFileSync(new URL('./watermarks.docx', import.meta.url), watermarkFixture());
diff --git a/src/documents/tests/fixtures/vml-watermarks/watermarks.docx b/src/documents/tests/fixtures/vml-watermarks/watermarks.docx
new file mode 100644
index 000000000..2b068e24d
Binary files /dev/null and b/src/documents/tests/fixtures/vml-watermarks/watermarks.docx differ
diff --git a/src/documents/tests/hidden-text-caret.test.js b/src/documents/tests/hidden-text-caret.test.js
new file mode 100644
index 000000000..7ca0bf18e
--- /dev/null
+++ b/src/documents/tests/hidden-text-caret.test.js
@@ -0,0 +1,51 @@
+import { afterEach, it, expect } from 'vitest';
+import { paragraph } from '../src/engine/model.js';
+import { revisionView } from '../src/engine/revisions.js';
+import { hiddenRun, hiddenStretchAt, sameShownPlace } from '../src/engine/hidden-text.js';
+
+const view = { ...revisionView, options: { ...revisionView.options } };
+afterEach(() => { Object.assign(revisionView, { ...view, options: { ...view.options } }); });
+
+// "Alpha beta gamma." with "beta " deleted and "new " inserted by a reviewer, then a second paragraph.
+const del = { type: 'del', author: 'Ada', date: '2026-10-27T00:00:00Z', id: 1 };
+const ins = { type: 'ins', author: 'Ada', date: '2026-10-27T00:00:00Z', id: 2 };
+const doc = () => {
+ const p = paragraph('');
+ p.runs = [{ ...p.runs[0], text: 'Alpha ' }, { ...p.runs[0], text: 'beta ', revision: del }, { ...p.runs[0], text: 'new ', revision: ins }, { ...p.runs[0], text: 'gamma.' }];
+ return [p, paragraph('Next')];
+};
+
+it('finds deleted text only in the views that hide it', () => {
+ const paragraphs = doc();
+ revisionView.mode = 'all';
+ expect(hiddenStretchAt(paragraphs, 8)).toBeNull();
+ for (const mode of ['simple', 'none']) {
+ revisionView.mode = mode;
+ expect(hiddenStretchAt(paragraphs, 8)).toEqual([6, 11]);
+ // Touching it from either side counts; the visible text beyond does not.
+ expect(hiddenStretchAt(paragraphs, 6)).toEqual([6, 11]);
+ expect(hiddenStretchAt(paragraphs, 11)).toEqual([6, 11]);
+ expect(hiddenStretchAt(paragraphs, 5)).toBeNull();
+ expect(hiddenStretchAt(paragraphs, 16)).toBeNull();
+ expect(sameShownPlace(paragraphs, 6, 11)).toBe(true);
+ expect(sameShownPlace(paragraphs, 5, 11)).toBe(false);
+ }
+ // Original hides the insertion instead.
+ revisionView.mode = 'original';
+ expect(hiddenStretchAt(paragraphs, 13)).toEqual([11, 15]);
+ expect(hiddenStretchAt(paragraphs, 8)).toBeNull();
+ // A reviewer's changes hidden in All Markup read as accepted: the deletion is hidden.
+ revisionView.mode = 'all'; revisionView.options.hiddenAuthors = ['Ada'];
+ expect(hiddenStretchAt(paragraphs, 8)).toEqual([6, 11]);
+});
+
+it('treats hidden text as hidden only while hidden text is not shown', () => {
+ const p = paragraph('');
+ p.runs = [{ ...p.runs[0], text: 'Visible ' }, { ...p.runs[0], text: 'secret', vanish: true }, { ...p.runs[0], text: ' end' }];
+ revisionView.showHidden = false;
+ expect(hiddenRun(p.runs[1])).toBe(true);
+ expect(hiddenStretchAt([p], 10)).toEqual([8, 14]);
+ revisionView.showHidden = true;
+ expect(hiddenRun(p.runs[1])).toBe(false);
+ expect(hiddenStretchAt([p], 10)).toBeNull();
+});
diff --git a/src/documents/tests/home-editing.test.js b/src/documents/tests/home-editing.test.js
index 180dea069..708f597a8 100644
--- a/src/documents/tests/home-editing.test.js
+++ b/src/documents/tests/home-editing.test.js
@@ -154,6 +154,22 @@ describe('Formatting marks', () => {
expect(tab.width).toBeGreaterThan(0);
expect(marks.find(m => m.kind === 'break').text).toBe('Page Break');
});
+ it('shows optional hyphens as ¬ where they do not break the line', async () => {
+ const { doc } = await styledDocument(['hy\u00adphen and opt\u00adional']);
+ const layout = layoutDocument(doc.paragraphs, measure, pageGeometry(doc.pageSettings), doc.numbering, doc.tables, undefined, { styles: doc.styles });
+ const marks = formattingMarks(layout.lines, doc.text, doc.paragraphs, doc.pageSettings, layout.page).filter(m => m.kind === 'optionalHyphen');
+ expect(marks).toHaveLength(2);
+ // At the position between the letters around it.
+ const line = layout.lines[0], at = offset => line.stops.find(s => s.offset === offset).x;
+ expect(marks[0].x).toBeCloseTo(at(2)); expect(marks[1].x).toBeCloseTo(at(doc.text.indexOf('\u00ad', 4)));
+ // Where one breaks the line it is drawn as the hyphen itself, with no second mark.
+ const narrow = layoutDocument(doc.paragraphs, measure, { ...pageGeometry(doc.pageSettings), right: pageGeometry(doc.pageSettings).width - pageGeometry(doc.pageSettings).left - 30 }, doc.numbering, doc.tables, undefined, { styles: doc.styles });
+ const broken = narrow.lines.filter(l => !l.paragraphEnd && doc.text[l.end - 1] === '\u00ad');
+ expect(broken.length).toBeGreaterThan(0);
+ const narrowMarks = formattingMarks(narrow.lines, doc.text, doc.paragraphs, doc.pageSettings, narrow.page).filter(m => m.kind === 'optionalHyphen');
+ expect(narrowMarks.some(m => broken.includes(m.cell))).toBe(false);
+ expect(narrowMarks.length).toBe(2 - broken.length);
+ });
it('shows end-of-cell marks in tables', async () => {
const { doc } = await styledDocument(['x']);
doc.insertTable(1, 2, 600);
@@ -177,6 +193,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);
diff --git a/src/documents/tests/home-signed-out.test.js b/src/documents/tests/home-signed-out.test.js
index 62209d4bd..74cd1a6f3 100644
--- a/src/documents/tests/home-signed-out.test.js
+++ b/src/documents/tests/home-signed-out.test.js
@@ -44,6 +44,9 @@ it('offers the sign-in in place of the list, then lists the documents once signe
expect(list.querySelector('.file-row')).toBeNull();
const account = view.el.querySelector('.user-btn');
expect(account.title).toBe('Not signed in');
+ // With nothing listed, sorting and searching have nothing to work on: they are not offered.
+ const tools = view.el.querySelector('.home-list-tools');
+ expect(tools.hidden).toBe(true);
// Writing needs no account at all.
[...list.querySelectorAll('button')].find((b) => b.textContent === 'New document').click();
@@ -53,6 +56,7 @@ it('offers the sign-in in place of the list, then lists the documents once signe
await vi.waitFor(() => expect(sdk.auth.signIn).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(list.querySelector('.file-row .file-name')?.textContent).toBe('Report'));
expect(list.textContent).not.toContain('Sign in to see your documents');
+ expect(tools.hidden).toBe(false);
expect(account.title).toBe('nariman');
expect(account.textContent).toBe('n');
});
diff --git a/src/documents/tests/hyperlink-fields.test.js b/src/documents/tests/hyperlink-fields.test.js
index 706852b8d..8e7bcbeb0 100644
--- a/src/documents/tests/hyperlink-fields.test.js
+++ b/src/documents/tests/hyperlink-fields.test.js
@@ -7,6 +7,7 @@ import {DocxPackage} from '../src/engine/docx.js';
import {TextDocument} from '../src/engine/model.js';
import {bodyFieldRanges} from '../src/engine/body-fields.js';
import {bodyFieldInstruction} from '../src/engine/docx-body-fields.js';
+import {openableHyperlink} from '../src/engine/hyperlinks.js';
let dom;beforeAll(()=>{dom=new JSDOM();globalThis.DOMParser=dom.window.DOMParser;globalThis.XMLSerializer=dom.window.XMLSerializer;});
afterAll(()=>{dom.window.close();delete globalThis.DOMParser;delete globalThis.XMLSerializer;});
const source=readFileSync('tests/fixtures/hyperlink-fields/control.docx');
@@ -60,7 +61,28 @@ it('keeps typing outside a field from inheriting its hidden link ownership',asyn
expect(model(await DocxPackage.open(await save(p,d))).text).toBe(d.text);
});
-it.each(['HYPERLINK "javascript:alert(1)"','HYPERLINK "relative.docx"','HYPERLINK \\m "x"','HYPERLINK \\l','HYPERLINK "https://example.com" \\l "Target"'])('keeps unsupported destination %s guarded',instruction=>expect(()=>bodyFieldInstruction(instruction)).toThrow());
+it.each(['HYPERLINK "javascript:alert(1)"','HYPERLINK "vbscript:msgbox(1)"','HYPERLINK "data:text/html,x"','HYPERLINK \\m "x"','HYPERLINK \\l','HYPERLINK "https://example.com" \\l "Target"'])('keeps unsupported destination %s guarded',instruction=>expect(()=>bodyFieldInstruction(instruction)).toThrow());
+
+it.each(['HYPERLINK "javascript:alert(1)"','HYPERLINK "vbscript:msgbox(1)"'])('names a script link, %s, plainly in the read-only notice',instruction=>{
+ expect(()=>bodyFieldInstruction(instruction)).toThrow(/This document contains a HYPERLINK field to a script or data address,/);
+});
+
+// Like a w:hyperlink element with the same target, a HYPERLINK field to a file, relative path or UNC path is kept
+// as it is: the document stays editable, the link is shown but never followed, and the field saves unchanged.
+it.each([['relative.docx','relative.docx'],['file:///C:/docs/report.pdf','file:///C:/docs/report.pdf'],['\\\\\\\\server\\\\share\\\\report.pdf','\\\\server\\share\\report.pdf']])('retains a field link to %s, not followed',async(code,target)=>{
+ expect(bodyFieldInstruction(`HYPERLINK "${code}"`).hyperlink).toMatchObject({target,retainedTarget:true});
+ const parts=unzipSync(source),xml=strFromU8(parts['word/document.xml']);
+ const at=xml.indexOf('')+''.length;
+ parts['word/document.xml']=strToU8(xml.slice(0,at)+`See HYPERLINK "${code}" the report`+xml.slice(at));
+ const bytes=zipSync(parts),p=await DocxPackage.open(bytes),d=model(p);
+ expect(p.readOnly).toBeFalsy();
+ expect(unzipSync(await save(p,d))).toEqual(unzipSync(bytes));
+ d.select(d.text.indexOf('the report')+4);expect(d.hyperlink.link).toMatchObject({target,retainedTarget:true});expect(openableHyperlink(d.hyperlink.link)).toBe(false);
+ d.replace('final ');
+ const again=model(await DocxPackage.open(await save(p,d)));
+ expect(again.text.startsWith('See the final report\n')).toBe(true);
+ expect(bodyFieldRanges(again.paragraphs)[0].field.instruction).toBe(` HYPERLINK "${code}" `);
+});
it('renames linked bookmarks through repeated edits and split instruction runs',async()=>{
const W='http://schemas.openxmlformats.org/wordprocessingml/2006/main',parts=unzipSync(source),doc=new DOMParser().parseFromString(strFromU8(parts['word/document.xml']),'application/xml');
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..6cd2b8700
--- /dev/null
+++ b/src/documents/tests/import-list-numbers.test.js
@@ -0,0 +1,77 @@
+// @vitest-environment jsdom
+import { describe, 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('- c
- d
')).toEqual(['3.\tc', '4.\td']);
+});
+
+it('keeps two lists one after another apart', () => {
+ expect(opened('- a
- b
- x
- y
')).toEqual(['1.\ta', '2.\tb', '1.\tx', '2.\ty']);
+ // The rule between them is a line under an empty paragraph.
+ 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('- a
- b
');
+ expect([a, b]).toEqual(['1.\ta', '2.\tb']);
+ expect(point).toMatch(/^ \S\tpoint$/);
+});
+
+describe('a web page Google Docs exports', () => {
+ // File > Download > Web page: nesting by class (lst-kix_-), each level a list of its own, numbering
+ // carried on by the style sheet, and a blank line an empty paragraph the style sheet gives a height.
+ const page = `
+Plan
+After a blank line
+- One
+
+- Two
+End
`;
+ // (Its blank lines need a browser's style sheets: tests/browser/google-docs-import.spec.js.)
+ it('keeps its nested lists, numbered on', async () => {
+ const { htmlBody } = await import('../src/lib/importers.js');
+ const lines = opened(htmlBody(page)).filter(Boolean);
+ expect(lines).toEqual(['Plan', 'After a blank line', '1.\tOne', expect.stringMatching(/^ \S\tDetail$/), expect.stringMatching(/^ \S\tMore$/), '2.\tTwo', 'End']);
+ });
+});
+
+describe('horizontal rules and table rows', () => {
+ const model = (html) => {
+ const doc = new TextDocument([paragraph()]), fragment = htmlFragment(html, { copied: false });
+ for (const p of fragment.paragraphs) p.mark = true;
+ pasteFragment(doc, fragment);
+ return doc;
+ };
+ it('keeps a horizontal rule as a line under an empty paragraph, as Word draws one', () => {
+ for (const html of ['Above
Below
', markdownHtml('Above\n\n---\n\nBelow\n')]) {
+ const doc = model(html);
+ expect(doc.paragraphs.map(p => p.runs.map(r => r.text).join(''))).toEqual(['Above', '', 'Below']);
+ expect(doc.paragraphs[1].format?.paragraphBorders?.bottom?.style).toBe('single');
+ expect(doc.paragraphs[0].format?.paragraphBorders?.bottom?.style ?? 'none').toBe('none');
+ expect(doc.paragraphs[2].format?.paragraphBorders?.bottom?.style ?? 'none').toBe('none');
+ }
+ });
+ it('keeps every cell in its column when cells before it are empty', () => {
+ expect(model('').paragraphs.map(p => p.runs.map(r => r.text).join(''))).toEqual(['\tQ1\tQ2', 'Sales\t\t9']);
+ });
+});
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..297dded3b
--- /dev/null
+++ b/src/documents/tests/imported-numbering-formats.test.js
@@ -0,0 +1,77 @@
+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('二〇二四');
+});
+
+// Korean counting and digits, Hebrew numerals and alphabet, the Arabic abjad and alphabet and the iroha.
+it('numbers lists in Korean, Hebrew, Arabic and iroha sequences', () => {
+ const sample = (format, values) => values.map(n => importedNumeral(n, format));
+ expect(sample('koreanCounting', [1, 10, 11, 20, 100, 101, 1234, 10000])).toEqual(['일', '십', '십일', '이십', '백', '백일', '천이백삼십사', '일만']);
+ expect(sample('koreanDigital', [1, 10, 205])).toEqual(['일', '일영', '이영오']);
+ expect(sample('hebrew1', [1, 10, 15, 16, 21, 100, 345, 500, 999, 1000])).toEqual(['א', 'י', 'טו', 'טז', 'כא', 'ק', 'שמה', 'תק', 'תתקצט', '1000']);
+ expect(sample('hebrew2', [1, 2, 22, 23])).toEqual(['א', 'ב', 'ת', '23']);
+ expect(sample('arabicAbjad', [1, 2, 3, 28, 29])).toEqual(['أ', 'ب', 'ج', 'غ', '29']);
+ expect(sample('arabicAlpha', [1, 2, 3, 28])).toEqual(['أ', 'ب', 'ت', 'ي']);
+ expect(sample('irohaFullWidth', [1, 2, 3, 47, 48])).toEqual(['イ', 'ロ', 'ハ', 'ス', '48']);
+});
diff --git a/src/documents/tests/importers.test.js b/src/documents/tests/importers.test.js
index 3ad6f125b..296f7bd04 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('')).toBe('Title
Body
');
});
@@ -116,6 +123,45 @@ describe('legacy Korean, Japanese and Chinese text', () => {
});
});
+describe('UTF-16 text without a byte-order mark', () => {
+ const le = text => new Uint8Array(Buffer.from(text, 'utf16le'));
+ const be = text => { const bytes = le(text); for (let i = 0; i < bytes.length; i += 2) [bytes[i], bytes[i + 1]] = [bytes[i + 1], bytes[i]]; return bytes; };
+ it('reads it by where its zero bytes fall, as editors that write no mark save it', () => {
+ for (const text of ['Привет, мир! Это текст.\r\nВторая строка.', 'Hello world\nSecond line', 'Καλημέρα κόσμε — 2024', '日本語のテキスト 123 abc']) {
+ expect(decodeTextFile(le(text))).toBe(text);
+ expect(decodeTextFile(be(text))).toBe(text);
+ }
+ });
+ it('leaves UTF-8 with a stray zero byte as UTF-8', () => {
+ for (const tail of [' und mehr Text hier', ' und mehr Text hier!']) {
+ expect(decodeTextFile(new Uint8Array([...Buffer.from('Grüße', 'utf8'), 0, ...Buffer.from(tail, 'utf8')]))).toBe(`Grüße\0${tail}`);
+ }
+ });
+});
+
+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 = [];
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`);
+ });
});
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);
diff --git a/src/documents/tests/line-numbers.test.js b/src/documents/tests/line-numbers.test.js
index ce042c9e5..17cf84579 100644
--- a/src/documents/tests/line-numbers.test.js
+++ b/src/documents/tests/line-numbers.test.js
@@ -95,6 +95,24 @@ describe('line number labels', () => {
expect(Number(label.text)).toBe(colLayout.lines.filter(l => l.page === 0 && l.column === 0 && !l.virtual).length + 1);
});
+ it('keeps automatic labels clear of the line between columns', () => {
+ const doc = new TextDocument([paragraph('word '.repeat(2000))], { lineNumbers: { countBy: 1, restart: 'newPage' }, columns: { count: 2, space: 720, equalWidth: true, separator: true } });
+ const layout = layoutOf(doc), result = lineNumberLabels(layout, doc.paragraphs, doc.numbering, doc.styles, measure);
+ const separators = layout.columnSeparators.filter(s => s.page === 0);
+ expect(separators).toHaveLength(1);
+ const line = separators[0], second = result.filter(l => l.page === 0 && l.x > 200);
+ expect(second.length).toBeGreaterThan(10);
+ // Before, a column-two label ended exactly on the line (half the 0.5 in gap from the column).
+ for (const label of second) {
+ expect(label.x).toBeGreaterThan(line.x + line.width);
+ expect(label.x + label.width).toBeLessThan(layout.lines.find(l => l.page === 0 && l.column === 1).columnLeft);
+ }
+ // An authored distance is kept as declared.
+ const authored = new TextDocument([paragraph('word '.repeat(2000))], { lineNumbers: { countBy: 1, restart: 'newPage', distance: 360 }, columns: { count: 2, space: 720, equalWidth: true, separator: true } });
+ const authoredLayout = layoutOf(authored), authoredLabel = lineNumberLabels(authoredLayout, authored.paragraphs, authored.numbering, authored.styles, measure).find(l => l.page === 0 && l.x > 200);
+ expect(authoredLabel.x + authoredLabel.width).toBeCloseTo(authoredLayout.lines.find(l => l.page === 0 && l.column === 1).columnLeft - 24);
+ });
+
it('uses the Line Number character style over the default paragraph style', async () => {
const pkg = await DocxPackage.open(readFileSync('public/fixtures/line-numbers.docx'));
const style = lineNumberStyle(pkg.styles);
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([]);
diff --git a/src/documents/tests/markdown-html.test.js b/src/documents/tests/markdown-html.test.js
index 339edc554..32f8b6d66 100644
--- a/src/documents/tests/markdown-html.test.js
+++ b/src/documents/tests/markdown-html.test.js
@@ -39,3 +39,8 @@ it('opens footnotes as their numbered notes, without the arrows a web page links
expect(lines).toContain('with more.');
expect(lines[0]).toBe('A claim1 and1 another2.');
});
+
+it('leaves out hidden HTML in a Markdown file, as opening a web page does', () => {
+ const lines = open('# Notes\n\nInternal: do not publish
\n\nShown secrettext.\n\nAlso hidden
\n\nEnd\n');
+ expect(lines).toEqual(['Notes', 'Shown text.', 'End']);
+});
diff --git a/src/documents/tests/math-linear.test.js b/src/documents/tests/math-linear.test.js
index 4db89e97f..3f5f2ab63 100644
--- a/src/documents/tests/math-linear.test.js
+++ b/src/documents/tests/math-linear.test.js
@@ -189,6 +189,32 @@ 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)');
+ 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 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.
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');
+ }
+});
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();
+});
diff --git a/src/documents/tests/page-number-parts.test.js b/src/documents/tests/page-number-parts.test.js
new file mode 100644
index 000000000..7d55b8925
--- /dev/null
+++ b/src/documents/tests/page-number-parts.test.js
@@ -0,0 +1,99 @@
+import { beforeAll, afterAll, it, expect } from 'vitest';
+import { JSDOM } from 'jsdom';
+import { readFileSync } from 'node:fs';
+import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
+import { DocxPackage } from '../src/engine/docx.js';
+import { TextDocument } from '../src/engine/model.js';
+import { layoutDocument } from '../src/engine/layout.js';
+import { layoutPageStories } from '../src/engine/page-story-layout.js';
+import { pageGeometry } from '../src/engine/page-settings.js';
+
+// Word's Insert > Page Number building blocks: headers and footers whose paragraphs sit in block-level
+// docPartObj controls (fixture: tests/fixtures/page-number-parts, independently authored XML).
+const source = readFileSync('public/fixtures/page-number-parts.docx');
+let dom;
+beforeAll(() => { dom = new JSDOM(); globalThis.DOMParser = dom.window.DOMParser; globalThis.XMLSerializer = dom.window.XMLSerializer; });
+afterAll(() => { dom.window.close(); delete globalThis.DOMParser; delete globalThis.XMLSerializer; });
+const model = (pkg, paragraphs = pkg.paragraphs, tables = pkg.tables) => new TextDocument(paragraphs, pkg.pageSettings, pkg.numbering, pkg.styles, tables, pkg.assets);
+const measure = text => ({ width: text.length * 7, ascent: 12, descent: 4 });
+const storyText = story => story.paragraphs.map(p => p.runs.map(r => r.text).join('')).join('\n');
+const save = async (pkg, body, edits) => unzipSync(new Uint8Array(await pkg.export(body.paragraphs, body.pageSettings, body.numbering, body.styles, body.tables, body.assets, edits).arrayBuffer()));
+
+it('opens Top of Page, Bottom of Page and Page X of Y headers and footers for editing, showing their numbers', async () => {
+ const pkg = await DocxPackage.open(source), body = model(pkg);
+ expect(pkg.readOnly).toBeFalsy();
+ const stories = pkg.pageStories.stories;
+ for (const path of ['word/header1.xml', 'word/footer1.xml', 'word/footer2.xml']) expect(stories[path].editable, path).toBe(true);
+ expect(stories['word/footer2.xml'].paragraphs[0].blockControls.map(c => /Bottom of Page/.test(c.shell) ? 'bottom' : 'top')).toEqual(['bottom', 'top']);
+ const layout = layoutDocument(body.paragraphs, measure, pageGeometry(body.pageSettings), body.numbering, body.tables, pkg.pageStories);
+ const placed = layoutPageStories(layout, pkg.pageStories, [body.pageSettings], measure, body.numbering, { styles: body.styles });
+ const text = path => placed.lines.filter(l => l.pageStory === path).map(l => [l.page, l.fragments.filter(f => !f.image).map(f => f.text).join('')]).filter(([, t]) => t);
+ expect(text('word/footer2.xml')).toEqual([[0, 'Page 1 of 3']]);
+ expect(text('word/footer1.xml')).toEqual([[1, '2'], [2, '3']]);
+ // The first page has its own (absent) first-page header.
+ expect(text('word/header1.xml')).toEqual([[1, '2'], [2, '3']]);
+ // Unedited, the package saves byte for byte.
+ expect(await save(pkg, body)).toEqual(unzipSync(source));
+});
+
+it('saves an edited page-number footer inside its building-block wrappers and reopens it editable', async () => {
+ const pkg = await DocxPackage.open(source), body = model(pkg);
+ const edits = {};
+ for (const path of ['word/footer1.xml', 'word/footer2.xml', 'word/header1.xml']) {
+ const story = pkg.pageStories.stories[path], doc = model(pkg, story.paragraphs, story.tables);
+ doc.select(0); doc.replace(path.includes('header') ? 'Chapter 1 ' : 'Draft · ');
+ edits[path] = { paragraphs: doc.paragraphs, tables: doc.tables };
+ }
+ const parts = await save(pkg, body, edits);
+ const footer = strFromU8(parts['word/footer2.xml']);
+ // The Bottom of Page wrapper holds the Top of Page wrapper, which holds the edited paragraph.
+ expect(footer).toMatch(/<\/w:docPartObj><\/w:sdtPr>.*.*Draft · .*NUMPAGES.*<\/w:p><\/w:sdtContent><\/w:sdt><\/w:sdtContent><\/w:sdt>/);
+ expect(strFromU8(parts['word/footer1.xml'])).toMatch(/<\/w:rPr><\/w:sdtEndPr>.*Draft · .*PAGE.*<\/w:p><\/w:sdtContent><\/w:sdt>/);
+ expect(strFromU8(parts['word/header1.xml'])).toMatch(/Page Numbers \(Top of Page\).*.*Chapter 1 .*<\/w:p><\/w:sdtContent><\/w:sdt>/);
+ for (const name of Object.keys(parts)) if (!/word\/(header1|footer[12])\.xml$/.test(name)) expect(parts[name], name).toEqual(unzipSync(source)[name]);
+ const again = await DocxPackage.open(zipSync(parts));
+ expect(storyText(again.pageStories.stories['word/footer2.xml'])).toBe('Draft · Page  of \n');
+ expect(again.pageStories.stories['word/footer2.xml'].editable).toBe(true);
+ expect(storyText(again.pageStories.stories['word/header1.xml'])).toBe('Chapter 1 \n');
+});
+
+it('keeps a locked or inline page-number control retained, as before', async () => {
+ const parts = unzipSync(source);
+ parts['word/footer1.xml'] = strToU8(strFromU8(parts['word/footer1.xml']).replace('', ''));
+ const pkg = await DocxPackage.open(zipSync(parts));
+ expect(pkg.readOnly).toBeFalsy();
+ expect(pkg.pageStories.stories['word/footer1.xml'].editable).toBe(false);
+ expect(storyText(pkg.pageStories.stories['word/footer1.xml'])).toBe('\n');
+ expect(pkg.pageStories.stories['word/footer2.xml'].editable).toBe(true);
+});
+
+// Adding a watermark to a page-numbered header gives it two sibling building blocks: Page Numbers (Top of Page)
+// and Watermarks, the latter holding the VML watermark. Their w:id values can repeat (here within the header, and a
+// copied header in another part repeats them too); the ids are kept in the wrappers and identify nothing else.
+it('edits a header holding page-number and watermark building blocks, whatever their ids', async () => {
+ const { watermarkFixture, TEXT_PICT, NS } = await import('./fixtures/vml-watermarks/fixture.js');
+ const sdt = (gallery, id, inner) => `${inner}`;
+ const page = ' PAGE 1';
+ const header = id => `\n${sdt('Page Numbers (Top of Page)', id, page)}${sdt('Watermarks', id, `${TEXT_PICT}`)}`;
+ const parts = unzipSync(watermarkFixture());
+ parts['word/header1.xml'] = strToU8(header('-1'));
+ parts['word/header2.xml'] = strToU8(header('-1'));
+ const bytes = zipSync(parts), pkg = await DocxPackage.open(bytes), body = model(pkg);
+ for (const path of ['word/header1.xml', 'word/header2.xml']) {
+ const story = pkg.pageStories.stories[path];
+ expect(story.editable, path).toBe(true);
+ expect(story.paragraphs.flatMap(p => p.runs.filter(r => r.vml).map(r => r.vml.watermark?.text))).toEqual(['CONFIDENTIAL']);
+ }
+ expect(await save(pkg, body)).toEqual(unzipSync(bytes));
+ const story = pkg.pageStories.stories['word/header1.xml'], doc = model(pkg, story.paragraphs, story.tables);
+ doc.select(0); doc.replace('Page ');
+ const edited = await save(pkg, body, { 'word/header1.xml': { paragraphs: doc.paragraphs, tables: doc.tables } });
+ const xml = strFromU8(edited['word/header1.xml']);
+ // Both wrappers, in order, each around its own paragraph; the watermark shape kept.
+ expect(xml).toMatch(/].*].*Page .*PAGE.*<\/w:p><\/w:sdtContent><\/w:sdt>].*].*PowerPlusWaterMarkObject.*<\/w:p><\/w:sdtContent><\/w:sdt><\/w:hdr>$/s);
+ expect(edited['word/header2.xml']).toEqual(parts['word/header2.xml']);
+ const again = await DocxPackage.open(zipSync(edited));
+ expect(again.readOnly).toBeFalsy();
+ expect(again.pageStories.stories['word/header1.xml'].editable).toBe(true);
+ expect(storyText(again.pageStories.stories['word/header1.xml'])).toBe('Page \n');
+});
diff --git a/src/documents/tests/paste-modes.test.js b/src/documents/tests/paste-modes.test.js
index 05617db3a..35d4019b1 100644
--- a/src/documents/tests/paste-modes.test.js
+++ b/src/documents/tests/paste-modes.test.js
@@ -2,7 +2,7 @@ import { beforeAll, afterAll, it, expect } from 'vitest';
import { JSDOM } from 'jsdom';
import { createDocx, WRITING_STYLES } from '../src/engine/docx.js';
import { TextDocument, paragraph } from '../src/engine/model.js';
-import { copyFragment, fragmentHtml, htmlFragment, pasteFragment } from '../src/engine/clipboard-fragment.js';
+import { copyFragment, fragmentHtml, htmlFragment, pasteFragment, carriesFormatting } from '../src/engine/clipboard-fragment.js';
import { mergedFragment, pastePayload, availablePasteModes } from '../src/engine/paste-modes.js';
let dom;
@@ -47,3 +47,47 @@ 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();
+});
+
+it('copies and pastes with formatting beside a footnote elsewhere in the document, but not over the note', () => {
+ const doc = documentWith(['Plain bold here', 'Has a note']);
+ doc.select(doc.text.length); doc.insertFootnoteReference('1', 'footnote');
+ expect(doc.hasNoteMarks).toBe(true);
+ doc.select(6, 10); doc.setRunFormat({ bold: true });
+ // A word of the first paragraph copies with its formatting.
+ doc.select(6, 10);
+ const fragment = copyFragment(doc);
+ expect(fragment.paragraphs[0].runs.map(r => [r.text, r.format.bold])).toEqual([['bold', true]]);
+ // Pasted into the first paragraph, which holds no note, it keeps it.
+ doc.select(15);
+ expect(carriesFormatting(doc)).toBe(true);
+ expect(pasteFragment(doc, htmlFragment(fragmentHtml(fragment)))).toBe(true);
+ expect(doc.paragraphs[0].runs.map(r => [r.text, !!r.bold])).toEqual([['Plain ', false], ['bold', true], [' here', false], ['bold', true]]);
+ // The paragraph holding the note, and a selection reaching the note, keep the plain-text path.
+ const note = doc.text.indexOf('\ufffc');
+ doc.select(note - 2);
+ expect(carriesFormatting(doc)).toBe(false);
+ doc.select(note - 4, note + 1);
+ expect(copyFragment(doc)).toBeNull();
+});
diff --git a/src/documents/tests/pdf-export.test.js b/src/documents/tests/pdf-export.test.js
index 43d3d992f..7a5b72161 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));
@@ -56,3 +56,85 @@ 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);
+});
+
+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`));
+});
+
+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 (ןאכ) האר');
+});
diff --git a/src/documents/tests/raster-size.test.js b/src/documents/tests/raster-size.test.js
new file mode 100644
index 000000000..9f66c2118
--- /dev/null
+++ b/src/documents/tests/raster-size.test.js
@@ -0,0 +1,37 @@
+// @vitest-environment jsdom
+import { it, expect, beforeAll, afterAll } from 'vitest';
+
+// Pictures over the editing limits are redrawn smaller on insertion: at most 4,096 pixels a side and
+// 16 megapixels. Rounding the scaled size can overshoot the pixel limit by a few pixels; the picture
+// must then lose those pixels, not be scaled down a second time to a quarter of its area.
+let rasterize, drawn;
+const saved = {};
+beforeAll(async () => {
+ saved.Image = globalThis.Image; saved.create = URL.createObjectURL; saved.revoke = URL.revokeObjectURL; saved.createElement = document.createElement;
+ URL.createObjectURL = () => 'blob:picture'; URL.revokeObjectURL = () => {};
+ // A decoded picture of the size its name says ("8198x8090").
+ globalThis.Image = class { set src(value) { this.url = value; } decode() { return Promise.resolve(); } };
+ const createElement = document.createElement.bind(document);
+ document.createElement = name => name !== 'canvas' ? createElement(name) : {
+ width: 0, height: 0,
+ getContext() { return { drawImage: (img, x, y, w, h) => { drawn = { w, h }; } }; },
+ toBlob(done) { done({ arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer }); },
+ };
+ ({ rasterize } = await import('../src/views/insert-tab.js'));
+});
+afterAll(() => { globalThis.Image = saved.Image; URL.createObjectURL = saved.create; URL.revokeObjectURL = saved.revoke; document.createElement = saved.createElement; });
+const sized = (width, height) => { const proto = globalThis.Image.prototype; Object.defineProperty(proto, 'naturalWidth', { configurable: true, get: () => width }); Object.defineProperty(proto, 'naturalHeight', { configurable: true, get: () => height }); };
+
+it('fits a large, nearly square picture within 16 megapixels without shrinking it twice', async () => {
+ for (const [width, height] of [[8198, 8090], [3991, 4071], [8000, 8000], [12000, 3000], [5000, 4999]]) {
+ sized(width, height);
+ const result = await rasterize(new Blob(['x']), 4096, { pixels: 16000000 });
+ expect(result.width * result.height, `${width}×${height}`).toBeLessThanOrEqual(16000000);
+ expect(Math.max(result.width, result.height), `${width}×${height}`).toBeLessThanOrEqual(4096);
+ // Within a pixel of the largest size that fits, keeping the proportions.
+ const scale = Math.min(4096 / Math.max(width, height), Math.sqrt(16000000 / (width * height)));
+ expect(Math.abs(result.width - width * scale), `${width}×${height}`).toBeLessThanOrEqual(2);
+ expect(Math.abs(result.height - height * scale), `${width}×${height}`).toBeLessThanOrEqual(2);
+ expect(drawn).toEqual({ w: result.width, h: result.height });
+ }
+});
diff --git a/src/documents/tests/review-navigation.test.js b/src/documents/tests/review-navigation.test.js
new file mode 100644
index 000000000..9767d6de9
--- /dev/null
+++ b/src/documents/tests/review-navigation.test.js
@@ -0,0 +1,121 @@
+// @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';
+import { revisionView } from '../src/engine/revisions.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 ']);
+});
+
+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 = []; }
+});
+
+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);
+});
+
+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');
+ }
+});
+
+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 = []; }
+});
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');
+});
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/spelling.test.js b/src/documents/tests/spelling.test.js
index b0670384b..00285a1a0 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);
@@ -25,6 +25,19 @@ describe('words to check', () => {
const m = wordsToCheck(text).find(w => w.word === 'recieve');
expect(text.slice(0, m.wordStart) + 'receive' + text.slice(m.wordEnd)).toBe('I saw receive’s notice and don’t care.');
});
+ it('checks words without their optional hyphens and zero-width characters, and locates them whole', () => {
+ const text = 'Manual hyph\u00adenation and zero\u200bwidth te\u200dst, rec\u00adieve\u00ad’s notice';
+ const list = wordsToCheck(text);
+ expect(list.map(w => w.word)).toEqual(['Manual', 'hyphenation', 'and', 'zero', 'width', 'test', 'recieve', 'notice']);
+ // A correction replaces the word with its optional hyphen, and keeps the possessive ending.
+ const hyphenated = list.find(w => w.word === 'hyphenation');
+ expect(text.slice(hyphenated.start, hyphenated.end)).toBe('hyph\u00adenation');
+ const possessive = list.find(w => w.word === 'recieve');
+ expect(text.slice(possessive.wordStart, possessive.wordEnd)).toBe('rec\u00adieve');
+ expect(text.slice(0, possessive.wordStart) + 'receive' + text.slice(possessive.wordEnd)).toBe('Manual hyph\u00adenation and zero\u200bwidth te\u200dst, receive\u00ad’s notice');
+ const speller = nspell(readFileSync('public/dictionaries/en-US.aff', 'utf8'), readFileSync('public/dictionaries/en-US.dic', 'utf8'));
+ expect(list.filter(w => !speller.correct(w.word)).map(w => w.word)).toEqual(['recieve']);
+ });
it('checks the parts of a hyphenated compound and reports their positions', () => {
const list = wordsToCheck('a well-knwn fact');
expect(list.map(w => [w.word, w.start, w.end])).toEqual([['a', 0, 1], ['well', 2, 6], ['knwn', 7, 11], ['fact', 12, 16]]);
@@ -51,4 +64,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([]);
+ });
});
diff --git a/src/documents/tests/story-field-update.test.js b/src/documents/tests/story-field-update.test.js
new file mode 100644
index 000000000..34ca87365
--- /dev/null
+++ b/src/documents/tests/story-field-update.test.js
@@ -0,0 +1,50 @@
+import { beforeAll, afterAll, it, expect } from 'vitest';
+import { JSDOM } from 'jsdom';
+import { readFileSync } from 'node:fs';
+import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
+import { DocxPackage } from '../src/engine/docx.js';
+import { TextDocument } from '../src/engine/model.js';
+import { updateStoryFields } from '../src/engine/field-results.js';
+
+// Update Fields in a header or footer: DATE, FILENAME and other evaluated fields there get their current result,
+// written inside each field's retained envelope. PAGE and NUMPAGES follow pagination as before.
+let dom;
+beforeAll(() => { dom = new JSDOM(); globalThis.DOMParser = dom.window.DOMParser; globalThis.XMLSerializer = dom.window.XMLSerializer; });
+afterAll(() => { dom.window.close(); delete globalThis.DOMParser; delete globalThis.XMLSerializer; });
+const when = new Date(2026, 9, 3, 14, 5);
+function source() {
+ const parts = unzipSync(readFileSync('public/fixtures/page-number-parts.docx'));
+ const complex = (code, cached, rPr = '') => ` ${code} ${rPr}${cached}`;
+ parts['word/header1.xml'] = strToU8(`Printed ${complex('DATE \\@ "MMMM d, yyyy"', 'January 1, 2000', '')} · old.docx · ${complex('PAGE', '9')}`);
+ return zipSync(parts);
+}
+const runs = model => model.paragraphs[0].runs.map(r => r.field ? `{${r.field.type}:${r.field.cachedText}}` : r.text).join('');
+
+it('refreshes evaluated fields in a header, saves them in their envelopes and undoes in one step', async () => {
+ const pkg = await DocxPackage.open(source()), story = pkg.pageStories.stories['word/header1.xml'];
+ expect(story.editable).toBe(true);
+ const model = new TextDocument(story.paragraphs, pkg.pageSettings, pkg.numbering, pkg.styles, story.tables, pkg.assets);
+ expect(runs(model)).toBe('Printed {DATE:January 1, 2000} · {FILENAME:old.docx} · {PAGE:9}');
+ expect(updateStoryFields(model, { now: when, fileName: 'plan.docx' })).toBe(2);
+ expect(runs(model)).toBe('Printed {DATE:October 3, 2026} · {FILENAME:plan.docx} · {PAGE:9}');
+ expect(updateStoryFields(model, { now: when, fileName: 'plan.docx' })).toBe(0);
+ const body = new TextDocument(pkg.paragraphs, pkg.pageSettings, pkg.numbering, pkg.styles, pkg.tables, pkg.assets);
+ const bytes = await pkg.export(body.paragraphs, body.pageSettings, body.numbering, body.styles, body.tables, body.assets, { 'word/header1.xml': { paragraphs: model.paragraphs, tables: model.tables } });
+ const xml = strFromU8(unzipSync(new Uint8Array(await bytes.arrayBuffer()))['word/header1.xml']);
+ // The result keeps the old result's formatting, inside the field's own delimiters.
+ expect(xml).toMatch(/ DATE \\@ "MMMM d, yyyy" <\/w:instrText><\/w:r>]*><\/w:r>]*><\/w:rPr>October 3, 2026<\/w:t><\/w:r>]*>/);
+ expect(xml).toMatch(/]*w:instr=" FILENAME "[^>]*>plan\.docx<\/w:t><\/w:r><\/w:fldSimple>/);
+ const again = await DocxPackage.open(new Uint8Array(await bytes.arrayBuffer()));
+ expect(runs(new TextDocument(again.pageStories.stories['word/header1.xml'].paragraphs))).toBe('Printed {DATE:October 3, 2026} · {FILENAME:plan.docx} · {PAGE:9}');
+ model.undo();
+ expect(runs(model)).toBe('Printed {DATE:January 1, 2000} · {FILENAME:old.docx} · {PAGE:9}');
+});
+
+it('leaves locked fields and fields it cannot evaluate as they are', async () => {
+ const parts = unzipSync(source());
+ parts['word/header1.xml'] = strToU8(strFromU8(parts['word/header1.xml']).replace('', '').replace(' FILENAME ', ' DOCVARIABLE Client '));
+ const pkg = await DocxPackage.open(zipSync(parts)), story = pkg.pageStories.stories['word/header1.xml'];
+ const model = new TextDocument(story.paragraphs, pkg.pageSettings, pkg.numbering, pkg.styles, story.tables, pkg.assets);
+ expect(updateStoryFields(model, { now: when, fileName: 'plan.docx' })).toBe(0);
+ expect(model.history).toHaveLength(0);
+});
diff --git a/src/documents/tests/strike-pairs.test.js b/src/documents/tests/strike-pairs.test.js
new file mode 100644
index 000000000..c8c1cbc93
--- /dev/null
+++ b/src/documents/tests/strike-pairs.test.js
@@ -0,0 +1,74 @@
+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';
+
+// §17.3.2.37 strike and §17.3.2.9 dstrike are separate on/off properties. Word, Pages and legal
+// templates write both in one property set when at most one is on: both switched off
+// (), or one on with the other off. That opened the whole
+// document read-only ("strike and dstrike on the same property set"). Each pair now resolves its two
+// declarations independently; both on, which would need a choice of form, still opens read-only.
+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 OFF = '', SINGLE = '', DOUBLE = '';
+function pkgBytes({ run = OFF, styleRPr = OFF, levelRPr = OFF } = {}) {
+ const styles = ``
+ + ``
+ + `${styleRPr}`
+ + `${styleRPr}`;
+ const numbering = `${levelRPr}`;
+ const body = `${run}Direct pair inherited`
+ + `Style pair`
+ + `Character pair`
+ + `Listed`;
+ return zipSync(Object.fromEntries(Object.entries({
+ '[Content_Types].xml': ``,
+ '_rels/.rels': ``,
+ 'word/_rels/document.xml.rels': ``,
+ 'word/styles.xml': styles, 'word/numbering.xml': numbering,
+ 'word/document.xml': `${body}`,
+ }).map(([name, xml]) => [name, strToU8(xml)])));
+}
+const model = pkg => new TextDocument(pkg.paragraphs, pkg.pageSettings, pkg.numbering, pkg.styles, pkg.tables, pkg.assets);
+const strike = (doc, text) => doc.paragraphs.flatMap(p => p.runs).find(r => r.text === text).strikethrough;
+const save = async (pkg, doc) => unzipSync(new Uint8Array(await pkg.export(doc.paragraphs, doc.pageSettings, doc.numbering, doc.styles, doc.tables, doc.assets).arrayBuffer()));
+
+it('opens both forms switched off for editing and clears an inherited double strikethrough', async () => {
+ const pkg = await DocxPackage.open(pkgBytes()), doc = model(pkg);
+ expect(pkg.readOnly).toBeFalsy();
+ expect(strike(doc, ' inherited')).toBe('double');
+ for (const text of ['Direct pair', 'Style pair', 'Character pair']) expect(strike(doc, text), text).toBe('none');
+ expect(pkg.numbering.abstracts['0'].levels[0].markerStyle.strikethrough).toBe('bothStrikeOff');
+});
+
+it('takes the form that is on when the other is switched off', async () => {
+ for (const [pair, expected] of [[SINGLE, 'single'], [DOUBLE, 'double']]) {
+ const doc = model(await DocxPackage.open(pkgBytes({ run: pair, styleRPr: pair, levelRPr: pair })));
+ for (const text of ['Direct pair', 'Style pair', 'Character pair']) expect(strike(doc, text), `${pair} ${text}`).toBe(expected);
+ }
+});
+
+it('keeps the pairs through an edit and writes one form when the strikethrough changes', async () => {
+ const pkg = await DocxPackage.open(pkgBytes()), doc = model(pkg);
+ doc.select(doc.text.indexOf('Listed') + 6); doc.replace(' item');
+ let parts = await save(pkg, doc);
+ expect(strFromU8(parts['word/styles.xml'])).toBe(strFromU8(unzipSync(pkgBytes())['word/styles.xml']));
+ expect(strFromU8(parts['word/document.xml'])).toContain(OFF);
+ const at = doc.text.indexOf('Direct pair'); doc.select(at, at + 11); doc.setRunFormat({ strikethrough: 'single' });
+ parts = await save(pkg, doc);
+ const reopened = model(await DocxPackage.open(zipSync(parts)));
+ expect(strike(reopened, 'Direct pair')).toBe('single');
+ expect(strFromU8(parts['word/document.xml'])).not.toContain(OFF);
+ // A style edited to clear both forms writes the pair.
+ doc.editParagraphStyle('Double', { run: { strikethrough: 'bothStrikeOff' } });
+ expect(strFromU8((await save(pkg, doc))['word/styles.xml'])).toMatch(/.*/);
+});
+
+it('still opens both forms switched on read-only', async () => {
+ await expect(DocxPackage.open(pkgBytes({ run: '' }))).rejects.toThrow('strike and dstrike both on');
+});
diff --git a/src/documents/tests/style-highlight-none.test.js b/src/documents/tests/style-highlight-none.test.js
new file mode 100644
index 000000000..77cea2403
--- /dev/null
+++ b/src/documents/tests/style-highlight-none.test.js
@@ -0,0 +1,37 @@
+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';
+
+// [MS-OI29500] §§2.1.245, 2.1.248, 2.1.260 and 2.1.291: Word does not allow highlighting in document
+// defaults or styles, so the editor refuses an active one. (§17.3.2.15)
+// highlights nothing, but it opened the whole document read-only as well. It is 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';
+function styled(highlight) {
+ const parts = unzipSync(docxWithParagraphs([{ text: 'Styled text' }]));
+ parts['word/document.xml'] = strToU8(strFromU8(parts['word/document.xml']).replace('', '').replace('', ''));
+ parts['word/styles.xml'] = strToU8(`${highlight}${highlight}${highlight}`);
+ parts['word/_rels/document.xml.rels'] = strToU8(``);
+ return zipSync(parts);
+}
+
+it('opens styles and document defaults that switch highlighting off for editing', async () => {
+ const pkg = await DocxPackage.open(styled(''));
+ expect(pkg.readOnly).toBeFalsy();
+ const doc = new TextDocument(pkg.paragraphs, pkg.pageSettings, pkg.numbering, pkg.styles, pkg.tables, pkg.assets);
+ const run = doc.paragraphs[0].runs[0];
+ expect([run.highlight, run.bold, run.italic]).toEqual(['none', true, true]);
+ doc.select(doc.text.length); doc.replace(' more');
+ 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/styles.xml'])).toBe(strFromU8(unzipSync(styled(''))['word/styles.xml']));
+});
+
+it('still opens a style that highlights read-only', async () => {
+ await expect(DocxPackage.open(styled(''))).rejects.toThrow('highlighting in document defaults');
+});
diff --git a/src/documents/tests/table-editing.test.js b/src/documents/tests/table-editing.test.js
index f28821d91..107f8ea8f 100644
--- a/src/documents/tests/table-editing.test.js
+++ b/src/documents/tests/table-editing.test.js
@@ -6,6 +6,7 @@ import { DocxPackage, createDocx, WRITING_STYLES } from '../src/engine/docx.js';
import { TextDocument, paragraph, paragraphText } from '../src/engine/model.js';
import { tableCells, verticalMerges, rowGrid, documentBlocks } from '../src/engine/tables.js';
import * as T from '../src/engine/table-editing.js';
+import { insertQuickTable } from '../src/engine/quick-tables.js';
import { blockFor, ownerPlacements } from '../src/engine/table-selection.js';
import { StorySession } from '../src/engine/story-session.js';
@@ -104,6 +105,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);
@@ -114,6 +148,40 @@ describe('rows and columns', () => {
});
});
+describe('with Track Changes on', () => {
+ // Removing rows, columns, cells or a whole table, merging, sorting and converting to text are not
+ // recorded as tracked changes; they refuse to run rather than remove text a reviewer would never see.
+ const tracked = () => { const { doc } = tableDocument(); doc.trackChanges = true; doc.revisionAuthor = 'Reviewer'; return doc; };
+ const refuses = (name, run) => {
+ const doc = tracked(), before = shape(doc), text = doc.text;
+ expect(() => run(doc), name).toThrow(/Track Changes/);
+ expect(shape(doc), name).toEqual(before);
+ expect(doc.text, name).toBe(text);
+ };
+ it('refuses the commands that would remove table text untracked, and leaves the table as it was', () => {
+ refuses('Delete Rows', doc => { at(doc, 1, 1); T.deleteRows(doc); });
+ refuses('Delete Columns', doc => { at(doc, 1, 1); T.deleteColumns(doc); });
+ refuses('Delete Table', doc => { at(doc, 1, 1); T.deleteTable(doc); });
+ refuses('Delete Cells', doc => { at(doc, 1, 1); T.deleteCells(doc, { shift: 'left' }); });
+ refuses('Merge Cells', doc => { block(doc, 0, 0, 1, 2); T.mergeBlock(doc); });
+ refuses('Sort', doc => { at(doc, 0, 0); T.sortTable(doc, { keys: [{ column: 0, type: 'text', descending: true }] }); });
+ refuses('Convert to Text', doc => { at(doc, 0, 0); T.convertTableToText(doc, { separator: 'tab' }); });
+ refuses('Eraser', doc => { const t = doc.tables[0]; T.eraseLine(doc, { tableId: t.id, cellIds: [t.rows[0].cells[0].id, t.rows[0].cells[1].id] }); });
+ refuses('Delete row (Table actions)', doc => { at(doc, 1, 1); doc.deleteTablePart('row'); });
+ });
+ it('still records the Delete key on cells, and allows commands that remove nothing', () => {
+ const doc = tracked();
+ block(doc, 0, 0, 0, 2); expect(T.clearCells(doc)).toBe(true);
+ expect(doc.paragraphs.some(p => p.runs.some(r => r.revision?.type === 'del'))).toBe(true);
+ at(doc, 1, 1); expect(T.insertRows(doc, {})).toBe(true);
+ expect(doc.tables[0].rows).toHaveLength(4);
+ // A Quick Table merges cells of the table it has just inserted: that still works.
+ doc.select(doc.text.length);
+ expect(insertQuickTable(doc, 'calendar', (r, c) => doc.insertTable(r, c), { date: new Date(2024, 1, 1) })).toBe(true);
+ expect(doc.tables.at(-1).rows[0].cells).toHaveLength(1);
+ });
+});
+
describe('cells', () => {
it('deletes cells shifting left (rows end early) and up', async () => {
const { pkg, doc } = tableDocument();
@@ -237,6 +305,38 @@ 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('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); };
@@ -255,6 +355,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);
@@ -270,6 +394,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];
diff --git a/src/documents/tests/table-formula.test.js b/src/documents/tests/table-formula.test.js
index 1b9fd645a..308fdd72d 100644
--- a/src/documents/tests/table-formula.test.js
+++ b/src/documents/tests/table-formula.test.js
@@ -92,3 +92,25 @@ 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');
+});
+
+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']);
+});
+
+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]);
+});
diff --git a/src/documents/tests/tracked-formatting.test.js b/src/documents/tests/tracked-formatting.test.js
index 75eda7825..e32068e16 100644
--- a/src/documents/tests/tracked-formatting.test.js
+++ b/src/documents/tests/tracked-formatting.test.js
@@ -168,6 +168,36 @@ describe('tracked moves', () => {
expect(again.model.text).toBe('Alpha gamma\nSecond paragraphbeta ');
});
+ it('accepts or rejects a move whole from either of its halves (§17.13.5.21–.24: one named move)', async () => {
+ const moved = () => {
+ const made = fresh();
+ made.session.run(m => { m.select(6, 11); m.replace(''); });
+ made.session.run(m => { m.select(m.text.length); m.replace('beta '); });
+ return made;
+ };
+ const cases = [
+ [true, 'ins', 'Alpha gamma\nSecond paragraphbeta '], [true, 'del', 'Alpha gamma\nSecond paragraphbeta '],
+ [false, 'ins', 'Alpha beta gamma\nSecond paragraph'], [false, 'del', 'Alpha beta gamma\nSecond paragraph'],
+ ];
+ for (const [accept, side, text] of cases) {
+ // At the caret, and as the one change a balloon or Next Change resolves (only that kind of change).
+ for (const own of [false, true]) {
+ const { model, session } = moved();
+ const half = model.revisions.find(r => r.move && r.type === side), at = half.start + 1;
+ session.run(m => m.resolveRevisions(accept, own ? half.start : at, own ? half.end : at, own ? { only: r => r.type === side } : undefined));
+ expect([accept, side, own, model.text]).toEqual([accept, side, own, text]);
+ expect(model.revisions).toEqual([]);
+ }
+ }
+ // A move opened from a file, linked by its name.
+ const { model } = await open(zip(packageFiles(
+ `Moved Stay`
+ + `End Moved `)));
+ model.resolveRevisions(false, model.text.length - 2, model.text.length - 2);
+ expect(model.text).toBe('Moved Stay\nEnd ');
+ expect(model.revisions).toEqual([]);
+ });
+
it('does not call ordinary typing a move', () => {
const { model, session } = fresh();
session.run(m => { m.select(0, 5); m.replace(''); });
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);
+});
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..caf2e4446
--- /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 both on in 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 both on in 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');
+});
diff --git a/src/documents/tests/vml-watermarks.test.js b/src/documents/tests/vml-watermarks.test.js
new file mode 100644
index 000000000..689a76fde
--- /dev/null
+++ b/src/documents/tests/vml-watermarks.test.js
@@ -0,0 +1,104 @@
+import { beforeAll, afterAll, it, expect } from 'vitest';
+import { JSDOM } from 'jsdom';
+import { zipSync, unzipSync, strFromU8 } from 'fflate';
+import { DocxPackage } from '../src/engine/docx.js';
+import { TextDocument, paragraphText } from '../src/engine/model.js';
+import { readVmlWatermark, vmlLength, vmlColor, vmlFraction, watermarkFrame } from '../src/engine/vml-watermarks.js';
+
+let dom;
+beforeAll(() => { dom = new JSDOM(); globalThis.DOMParser = dom.window.DOMParser; globalThis.XMLSerializer = dom.window.XMLSerializer; });
+afterAll(() => { dom.window.close(); delete globalThis.DOMParser; delete globalThis.XMLSerializer; });
+
+import { watermarkFixture as fixture, TEXT_PICT, PICTURE_PICT, PNG, NS } from './fixtures/vml-watermarks/fixture.js';
+
+const watermarks = story => story.paragraphs.flatMap(p => p.runs.filter(r => r.vml).map(r => r.vml.watermark));
+
+it('reads Word text and picture watermarks', () => {
+ const doc = new DOMParser().parseFromString(`${TEXT_PICT}`, 'application/xml');
+ const text = readVmlWatermark(doc.documentElement.firstElementChild);
+ expect(text).toMatchObject({ kind: 'text', text: 'CONFIDENTIAL', font: 'Calibri', color: 'C0C0C0', opacity: 0.5, rotation: 315, behind: true, width: vmlLength('527.85pt'),
+ horizontal: { align: 'center', relativeFrom: 'margin' }, vertical: { align: 'center', relativeFrom: 'margin' } });
+ const picture = readVmlWatermark(new DOMParser().parseFromString(`${PICTURE_PICT}`, 'application/xml').documentElement.firstElementChild);
+ expect(picture).toMatchObject({ kind: 'picture', imageRel: 'rIdWm', width: 200 * 12700, height: 100 * 12700 });
+ expect(picture.gain).toBeCloseTo(0.3, 2);
+ expect([vmlColor('#c0c0c0'), vmlColor('#abc'), vmlColor('silver [4]'), vmlFraction('32768f'), vmlFraction('.5'), vmlLength('1in')]).toEqual(['C0C0C0', 'AABBCC', 'C0C0C0', 0.5, 0.5, 914400]);
+ // Not a watermark: an ordinary VML shape in a run.
+ expect(readVmlWatermark(new DOMParser().parseFromString(``, 'application/xml').documentElement)).toBe(null);
+});
+
+it('opens a document with Word watermarks for editing, keeps them on save and after editing the header', async () => {
+ const bytes = fixture(), pkg = await DocxPackage.open(bytes);
+ expect(pkg.readOnly).toBeFalsy();
+ expect(pkg.importIssues || []).toEqual([]);
+ const first = pkg.pageStories.stories['word/header1.xml'], second = pkg.pageStories.stories['word/header2.xml'];
+ expect(first.editable).toBe(true);
+ expect(watermarks(first)).toMatchObject([{ kind: 'text', text: 'CONFIDENTIAL' }]);
+ expect(watermarks(second)).toMatchObject([{ kind: 'picture', imageRel: 'rIdWm' }]);
+ expect(paragraphText(first.paragraphs[0])).toBe('Acme internal');
+ // Saved untouched: every part as it was.
+ const model = new TextDocument(pkg.paragraphs, pkg.pageSettings, pkg.numbering, pkg.styles, pkg.tables, pkg.assets);
+ const save = async edits => unzipSync(new Uint8Array(await pkg.export(model.paragraphs, model.pageSettings, model.numbering, model.styles, model.tables, model.assets, edits).arrayBuffer()));
+ const original = unzipSync(bytes), untouched = await save();
+ for (const path of ['word/header1.xml', 'word/header2.xml', 'word/media/watermark.png', 'word/_rels/header2.xml.rels']) expect(strFromU8(untouched[path]), path).toBe(strFromU8(original[path]));
+ // The header's own text edited: the watermark is written again unchanged, before the edited text.
+ const story = new TextDocument(first.paragraphs, pkg.pageSettings, pkg.numbering, pkg.styles, first.tables, pkg.assets);
+ story.select(story.text.length); story.replace(' (draft)');
+ const second2 = new TextDocument(second.paragraphs, pkg.pageSettings, pkg.numbering, pkg.styles, second.tables, pkg.assets);
+ second2.select(second2.text.length); second2.replace('Logo page');
+ const edited = await save({ 'word/header1.xml': { paragraphs: story.paragraphs, tables: story.tables }, 'word/header2.xml': { paragraphs: second2.paragraphs, tables: second2.tables } });
+ const header1 = strFromU8(edited['word/header1.xml']);
+ expect(header1).toContain('Acme internal (draft)');
+ const pict = //.exec(header1)[0];
+ expect(pict).toContain('id="PowerPlusWaterMarkObject357476642"');
+ expect(pict).toContain('string="CONFIDENTIAL"');
+ expect(header1.indexOf(' {
+ const page = { width: 816, height: 1056, left: 96, right: 96, top: 96, bottom: 96 };
+ const centred = { width: 9525 * 200, height: 9525 * 100, horizontal: { align: 'center', relativeFrom: 'margin', offset: 0 }, vertical: { align: 'center', relativeFrom: 'margin', offset: 0 } };
+ expect(watermarkFrame(centred, page)).toEqual({ x: 308, y: 478, width: 200, height: 100 });
+ expect(watermarkFrame({ ...centred, horizontal: { align: null, relativeFrom: 'page', offset: 9525 * 10 }, vertical: { align: 'bottom', relativeFrom: 'page', offset: 0 } }, page)).toEqual({ x: 10, y: 956, width: 200, height: 100 });
+});
+
+it('Design › Watermark replaces or removes a Word watermark instead of adding a second one', async () => {
+ const { setWatermarkParts } = await import('../src/engine/package-design.js');
+ const marks = xml => ({ word: (xml.match(/PowerPlusWaterMarkObject|WordPictureWatermark/g) || []).length, ours: (xml.match(/name="Document Watermark"/g) || []).length });
+ let parts = unzipSync(fixture());
+ setWatermarkParts(parts, { kind: 'text', text: 'SAMPLE', size: 'auto', color: 'C0C0C0', semitransparent: true, layout: 'horizontal' });
+ for (const path of ['word/header1.xml', 'word/header2.xml']) expect(marks(strFromU8(parts[path])), path).toEqual({ word: 0, ours: 1 });
+ // The header's own text stays.
+ expect(strFromU8(parts['word/header1.xml'])).toContain('Acme internal');
+ const reopened = await DocxPackage.open(zipSync(parts));
+ expect(reopened.readOnly).toBeFalsy();
+ expect(watermarks(reopened.pageStories.stories['word/header1.xml'])).toEqual([]);
+ // Remove Watermark removes Word's too.
+ parts = unzipSync(fixture());
+ setWatermarkParts(parts, null);
+ for (const path of ['word/header1.xml', 'word/header2.xml']) expect(marks(strFromU8(parts[path])), path).toEqual({ word: 0, ours: 0 });
+ expect(strFromU8(parts['word/header1.xml'])).toContain('Acme internal');
+ expect((await DocxPackage.open(zipSync(parts))).readOnly).toBeFalsy();
+});
+
+it('Design › Watermark reads a Word watermark as the current one', async () => {
+ const { readWatermark } = await import('../src/engine/package-design.js');
+ const parts = unzipSync(fixture());
+ expect(readWatermark(parts)).toEqual({ kind: 'text', text: 'CONFIDENTIAL', font: 'Calibri', size: 'auto', color: 'C0C0C0', semitransparent: true, layout: 'diagonal' });
+ // Reading changes nothing.
+ expect(unzipSync(zipSync(parts))['word/header1.xml']).toEqual(unzipSync(fixture())['word/header1.xml']);
+ // A picture watermark alone: its picture, size in pixels and washout.
+ const header1 = strFromU8(parts['word/header1.xml']).replace(/[\s\S]*<\/w:pict>/, '');
+ const picture = readWatermark({ ...parts, 'word/header1.xml': new TextEncoder().encode(header1) });
+ expect(picture).toMatchObject({ kind: 'picture', mime: 'image/png', width: 40, height: 20, scale: 'auto', washout: true });
+ expect([...picture.bytes]).toEqual([...PNG]);
+ // A horizontal one in a solid colour.
+ const flat = strFromU8(parts['word/header1.xml']).replace('rotation:315;', '').replace('fillcolor="silver"', 'fillcolor="#FF0000"').replace('', '');
+ expect(readWatermark({ ...parts, 'word/header1.xml': new TextEncoder().encode(flat) })).toMatchObject({ layout: 'horizontal', color: 'FF0000', semitransparent: false });
+});
diff --git a/src/documents/tools/numbering_definition_recovery_fixture.py b/src/documents/tools/numbering_definition_recovery_fixture.py
index e65e56ccb..9a843d7e1 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 = 'thaiCounting' 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="thaiCounting"') + '' 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..36b34fec7 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('thaiCounting', 'decimal')
abstract = re.search(r'.*?', numbering).group()
instance = ''
variants = {