diff --git a/docs/architecture/pagination.md b/docs/architecture/pagination.md index 64106af..5bea1c6 100644 --- a/docs/architecture/pagination.md +++ b/docs/architecture/pagination.md @@ -117,4 +117,4 @@ back from the other. ## Multi-column sections (`columns.ts` + `columnsFlow.ts`) -- **`columns.ts`** (`Columns`) + **`columnsFlow.ts`** (`ColumnsFlow`) — multi-column (newspaper) layout with **cross-page flow** and **Word's sequential fill order**: column 1 fills to the page bottom, then column 2, then the next page — via per-fragment node decorations carrying `height:…;column-fill:auto` (full page slot for fragments with a continuation; content height + `OPEN_HEIGHT_SLACK_PX` for the open last fragment of a document-final chain, so typing grows column 1 first without one-frame flashes into column 2). A chain followed by real content keeps its last fragment undecorated → CSS `column-fill: balance` (Word balances the end of a continuous section) — which only works because the paragraph mark's `::after` is `position: static` inside `.columns-node` (`editor.css`): the absolutely-positioned mark made Chromium give up balancing entirely and stack every block into column 1 (probed; a list's absolute `::before` markers do not trigger this). Because the box height is the plugin's own decoration, overflow/join decisions use content-based estimates (`contentHeightPx` = block rects + collapsed margins), not the rendered height. A section is a block region (`content: '(paragraph|heading|bulletList|orderedList)+'`) rendered as CSS multi-column (`column-count`/`column-gap` inline via `renderHTML`; no NodeView, no visual affordance — it must read as plain flowing text, so NOT isolating/selectable: selection, Backspace, and joins cross its boundary). Attrs: `count` (2–3) and `gapCm` (default `DEFAULT_COLUMN_GAP_CM` 0.5, clamp 0–5). Own group like textBox (Document is `'(block | textBox | columns)+'`). **Chain model:** the document may hold a section as several *adjacent equal-attr fragments* — the user-visible section is the maximal run (`columnsChain`); commands, export, and import all treat a chain as one section. **Cross-page flow (`columnsFlow.ts`):** a rAF layout pass (mirroring pageBreaks' geometry via the exported `readVerticalMargins`) keeps the fragmentation in sync with the page grid — an overflowing fragment is **split** at a block boundary so the maximal prefix fills its page (balanced-height estimate = Σ block `getClientRects` heights / count), and a fragment with room **joins** the adjacent continuation back (the split pass then re-splits at the right point). One op per pass, `addToHistory: false` (layout-only; undo of user edits just re-flows), composition-guarded, pass-budgeted (`MAX_FLOW_PASSES`, reset per external change). **pageBreaks.ts cooperation:** a `.columns-node` leaf carries `columnsFragment` (block count + first-block fit estimate); on bottom overflow pageBreaks *waits for the flow* when the fragment is splittable and its first block fits the remaining space, pushes it whole when mid-page (even taller-than-page), and at a page top does nothing (`columns-line-split-pending` — pushing a page-tall fragment just moves the problem down forever). Both sides use the shared `COLUMNS_FIT_MARGIN_PX` for the first-block fit test — differing thresholds make split/push/join cycle at the boundary; the flow's join `HYSTERESIS_PX` must exceed the estimate error for the same reason. The **overflow** test carries the same kind of headroom (`SAFETY_PX`): the estimate averages the flow over the columns, but a decorated column ends on a whole line, so a fragment measuring exactly its slot spills once the decoration pins the height — without it the flow reads its own last answer, measuring "fits" undecorated and "overflows" decorated forever. Every split or join therefore also clears the decoration cache key (`dispatchFlow`): ProseMirror drops node decorations whose node was replaced, and a stale key leaves the fragment at its balanced height instead of its page slot. **Page boundaries cut mid-paragraph at line boundaries** (`splitParagraphInBlock`): the flow measures a paragraph's rendered lines (`groupLines` — document-order rects banded by *overlap*: a rect joins the line it overlaps by half the taller of the two, so a raised run like a verse number is no line of its own and no outsized rect swallows the line below it), converts the page's leftover content budget into a line count by summing the **real** top-to-top advances (`linesWithin` — a line carrying a raised run is taller than the rest, so one median advance for all of them runs ~15% short; the median stands in only where the tops reset at a column break), finds the boundary text position by probing **both** edges of the line's first rect and keeping the earlier position (`lineStartPos` — on a right-to-left line the logical start is the right edge, and `left + 1` alone keeps a word too many, which costs one split pass per line), and does a depth-2 `tr.split` whose second part carries the layout-internal **`joinPrev`** paragraph attr (a global attr minted by the Columns extension; never rendered/parsed, `keepOnSplit: false`). Used in two places: a paragraph taller than the whole page slot (`force` — at least one line stays), and the **boundary block** after `k` whole blocks fit (leftover room ≥ `MIN_TAIL_LINES` — so the last column of a page is filled instead of pushing the next paragraph out whole); the join side mirrors it by pulling a multi-line paragraph head at ~two line rows of room (partial pull; the re-split hands the excess back). A live per-fragment analysis is exposed via `getColumnsFlowDebug` and included in the dev Debug dump (`columnsFlow` key). pageBreaks skips the empty trailing paragraph after a document-final chain (`trailing-empty-after-columns`) so a sequential-fill box reaching the page bottom doesn't mint a phantom page. Joins reverse it: a fragment join uses depth 2 when the continuation head is a joinPrev paragraph (needing only ~one line row of space), and a stray joinPrev paragraph *inside* a fragment merges into its predecessor immediately. `mergeJoinedBlocks` (columns.ts) restores whole paragraphs on unwrap/absorb; `mergeJoinedParagraphsJson` (export/odt.ts, shared with docx.ts) does the same before serializing, so files always hold the original single paragraph. Commands: `setColumns(n)` — inside a section 1 unwraps the whole chain / 2–3 update it; outside with a selection, 2–3 wrap the covered top-level blocks (absorbing existing sections; fail on tables/boxes/TOCs); with a **bare cursor, the whole document** gets columns (Word behavior; wrappable runs around tables become one section each) — and `setColumnGap(g)` (chain-wide); helper `findColumns`. UI: a Columns dropdown in `ToolbarExpanded.svelte` (1/2/3 buttons + gap input, tick-derived active state). `trailingNode.ts` appends a paragraph after a trailing section. **ODF**: exported via the textBox-style sentinel hoist (`replaceColumns`/`applyColumns`, U+E009) as `` + a minted section style with `` — adjacent equal-attr fragments **coalesce into one section**; import resolves via `StyleResolver.sectionColumns` (gap also derived from per-`style:column` indents), wraps runs of allowed blocks (`pushColumnRuns`), moves tables/boxes out with a warning (columns nodes from inner sections pass through silently), clamps >3 columns (the flow re-fragments after load); Word-style **page-layout columns** (`` in `style:page-layout-properties`, no `text:section`) are read via `StyleResolver.pageColumns` and wrap the whole body the same way. **DOCX**: full support — export splits the body into multiple `docx`-lib sections (`bodyGroups`; continuous breaks, `w:cols` with gap in twips, page geometry + fresh header/footer refs per sectPr, fragments coalesced); import groups body children on mid-body `pPr>sectPr` paragraphs (`splitBodySections`, empty markers dropped) and wraps groups whose sectPr has `w:cols w:num>1` — covering whole-document multi-column files via the body-final sectPr. A section whose own `w:type` is a page-starting break (nextPage/oddPage/evenPage, or absent = the default; `sectionStartsNewPage`, not `continuous`/`nextColumn`) tags its first block with `breakBefore:'page'` so the section starts on a new page like Word. +- **`columns.ts`** (`Columns`) + **`columnsFlow.ts`** (`ColumnsFlow`) — multi-column (newspaper) layout with **cross-page flow** and **Word's sequential fill order**: column 1 fills to the page bottom, then column 2, then the next page — via per-fragment node decorations carrying `height:…;column-fill:auto` (full page slot for fragments with a continuation; content height + `OPEN_HEIGHT_SLACK_PX` for the open last fragment of a document-final chain, so typing grows column 1 first without one-frame flashes into column 2). A chain followed by real content keeps its last fragment undecorated → CSS `column-fill: balance` (Word balances the end of a continuous section) — which only works because the paragraph mark's `::after` is `position: static` inside `.columns-node` (`editor.css`): the absolutely-positioned mark made Chromium give up balancing entirely and stack every block into column 1 (probed; a list's absolute `::before` markers do not trigger this). Because the box height is the plugin's own decoration, overflow/join decisions use content-based estimates (`contentHeightPx` = block rects + collapsed margins), not the rendered height. A section is a block region (`content: '(paragraph|heading|bulletList|orderedList)+'`) rendered as CSS multi-column (`column-count`/`column-gap` inline via `renderHTML`; no NodeView, no visual affordance — it must read as plain flowing text, so NOT isolating/selectable: selection, Backspace, and joins cross its boundary). Attrs: `count` (2–3) and `gapCm` (default `DEFAULT_COLUMN_GAP_CM` 0.5, clamp 0–5). Own group like textBox (Document is `'(block | textBox | columns)+'`). **Chain model:** the document may hold a section as several *adjacent equal-attr fragments* — the user-visible section is the maximal run (`columnsChain`); commands, export, and import all treat a chain as one section. **Cross-page flow (`columnsFlow.ts`):** a rAF layout pass (mirroring pageBreaks' geometry via the exported `readVerticalMargins`) keeps the fragmentation in sync with the page grid — an overflowing fragment is **split** at a block boundary so the maximal prefix fills its page (balanced-height estimate = Σ block `getClientRects` heights / count), and a fragment with room **joins** the adjacent continuation back (the split pass then re-splits at the right point). One op per pass, `addToHistory: false` (layout-only; undo of user edits just re-flows) — **but prosemirror-history rebases its own steps over that op**, so an undo reaching back past one can leave a wrap standing that nothing can undo; `tests/monkey/` counts the flow's ops (`columnsFlowTx`) and leaves the history unchecked for such a run rather than assert a ceiling the design carries, composition-guarded, pass-budgeted (`MAX_FLOW_PASSES`, reset per external change). **pageBreaks.ts cooperation:** a `.columns-node` leaf carries `columnsFragment` (block count + first-block fit estimate); on bottom overflow pageBreaks *waits for the flow* when the fragment is splittable and its first block fits the remaining space, pushes it whole when mid-page (even taller-than-page), and at a page top does nothing (`columns-line-split-pending` — pushing a page-tall fragment just moves the problem down forever). Both sides use the shared `COLUMNS_FIT_MARGIN_PX` for the first-block fit test — differing thresholds make split/push/join cycle at the boundary; the flow's join `HYSTERESIS_PX` must exceed the estimate error for the same reason. The **overflow** test carries the same kind of headroom (`SAFETY_PX`): the estimate averages the flow over the columns, but a decorated column ends on a whole line, so a fragment measuring exactly its slot spills once the decoration pins the height — without it the flow reads its own last answer, measuring "fits" undecorated and "overflows" decorated forever. Every split or join therefore also clears the decoration cache key (`dispatchFlow`): ProseMirror drops node decorations whose node was replaced, and a stale key leaves the fragment at its balanced height instead of its page slot. **Page boundaries cut mid-paragraph at line boundaries** (`splitParagraphInBlock`): the flow measures a paragraph's rendered lines (`groupLines` — document-order rects banded by *overlap*: a rect joins the line it overlaps by half the taller of the two, so a raised run like a verse number is no line of its own and no outsized rect swallows the line below it), converts the page's leftover content budget into a line count by summing the **real** top-to-top advances (`linesWithin` — a line carrying a raised run is taller than the rest, so one median advance for all of them runs ~15% short; the median stands in only where the tops reset at a column break), finds the boundary text position by probing **both** edges of the line's first rect and keeping the earlier position (`lineStartPos` — on a right-to-left line the logical start is the right edge, and `left + 1` alone keeps a word too many, which costs one split pass per line), and does a depth-2 `tr.split` whose second part carries the layout-internal **`joinPrev`** paragraph attr (a global attr minted by the Columns extension; never rendered/parsed, `keepOnSplit: false`). Used in two places: a paragraph taller than the whole page slot (`force` — at least one line stays), and the **boundary block** after `k` whole blocks fit (leftover room ≥ `MIN_TAIL_LINES` — so the last column of a page is filled instead of pushing the next paragraph out whole); the join side mirrors it by pulling a multi-line paragraph head at ~two line rows of room (partial pull; the re-split hands the excess back). A live per-fragment analysis is exposed via `getColumnsFlowDebug` and included in the dev Debug dump (`columnsFlow` key). pageBreaks skips the empty trailing paragraph after a document-final chain (`trailing-empty-after-columns`) so a sequential-fill box reaching the page bottom doesn't mint a phantom page. Joins reverse it: a fragment join uses depth 2 when the continuation head is a joinPrev paragraph (needing only ~one line row of space), and a stray joinPrev paragraph *inside* a fragment merges into its predecessor immediately. `mergeJoinedBlocks` (columns.ts) restores whole paragraphs on unwrap/absorb; `mergeJoinedParagraphsJson` (export/odt.ts, shared with docx.ts) does the same before serializing, so files always hold the original single paragraph. Commands: `setColumns(n)` — inside a section 1 unwraps the whole chain / 2–3 update it; outside with a selection, 2–3 wrap the covered top-level blocks (absorbing existing sections; fail on tables/boxes/TOCs); with a **bare cursor, the whole document** gets columns (Word behavior; wrappable runs around tables become one section each) — and `setColumnGap(g)` (chain-wide); helper `findColumns`. UI: a Columns dropdown in `ToolbarExpanded.svelte` (1/2/3 buttons + gap input, tick-derived active state). `trailingNode.ts` appends a paragraph after a trailing section. **ODF**: exported via the textBox-style sentinel hoist (`replaceColumns`/`applyColumns`, U+E009) as `` + a minted section style with `` — adjacent equal-attr fragments **coalesce into one section**; import resolves via `StyleResolver.sectionColumns` (gap also derived from per-`style:column` indents), wraps runs of allowed blocks (`pushColumnRuns`), moves tables/boxes out with a warning (columns nodes from inner sections pass through silently), clamps >3 columns (the flow re-fragments after load); Word-style **page-layout columns** (`` in `style:page-layout-properties`, no `text:section`) are read via `StyleResolver.pageColumns` and wrap the whole body the same way. **DOCX**: full support — export splits the body into multiple `docx`-lib sections (`bodyGroups`; continuous breaks, `w:cols` with gap in twips, page geometry + fresh header/footer refs per sectPr, fragments coalesced); import groups body children on mid-body `pPr>sectPr` paragraphs (`splitBodySections`, empty markers dropped) and wraps groups whose sectPr has `w:cols w:num>1` — covering whole-document multi-column files via the body-final sectPr. A section whose own `w:type` is a page-starting break (nextPage/oddPage/evenPage, or absent = the default; `sectionStartsNewPage`, not `continuous`/`nextColumn`) tags its first block with `breakBefore:'page'` so the section starts on a new page like Word. diff --git a/docs/showcase/book.docx b/docs/showcase/book.docx index 19a20bc..d832459 100644 Binary files a/docs/showcase/book.docx and b/docs/showcase/book.docx differ diff --git a/docs/showcase/book.odt b/docs/showcase/book.odt index 6c2e4c6..ba6c34b 100644 Binary files a/docs/showcase/book.odt and b/docs/showcase/book.odt differ diff --git a/docs/showcase/newsletter.docx b/docs/showcase/newsletter.docx index 80f42ad..1b92c94 100644 Binary files a/docs/showcase/newsletter.docx and b/docs/showcase/newsletter.docx differ diff --git a/docs/showcase/newsletter.odt b/docs/showcase/newsletter.odt index 024ece1..47c012f 100644 Binary files a/docs/showcase/newsletter.odt and b/docs/showcase/newsletter.odt differ diff --git a/docs/showcase/thesis-review.docx b/docs/showcase/thesis-review.docx index 2c33798..306c4fe 100644 Binary files a/docs/showcase/thesis-review.docx and b/docs/showcase/thesis-review.docx differ diff --git a/docs/showcase/thesis-review.odt b/docs/showcase/thesis-review.odt index d14b74b..d88968e 100644 Binary files a/docs/showcase/thesis-review.odt and b/docs/showcase/thesis-review.odt differ diff --git a/docs/showcase/thesis.docx b/docs/showcase/thesis.docx index c554849..23c6efb 100644 Binary files a/docs/showcase/thesis.docx and b/docs/showcase/thesis.docx differ diff --git a/docs/showcase/thesis.odt b/docs/showcase/thesis.odt index 7f805fa..d5f2e73 100644 Binary files a/docs/showcase/thesis.odt and b/docs/showcase/thesis.odt differ diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index 4e5e929..a0f20e0 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -81,6 +81,8 @@ import { markupView } from '../storage/markup.svelte'; import { loadDocProperties } from '../storage/docProperties'; import { Insertion, Deletion, TrackChanges } from './extensions/trackChanges'; +const CELL_CONTENT = '(paragraph | heading | bulletList | orderedList)+'; + export const extensions = [ // columns has its own group so only the document (not cells/lists) admits it; the // note section is last or nowhere. A text box is inline and rides a paragraph. @@ -213,8 +215,10 @@ export const extensions = [ }, }).configure({ resizable: false, View: TableView }), ResizableTableRow, - TableHeader, - TableCell, + // A cell holds what both exporters can write into one, which is also what the importers + // salvage a nested table into — the command's own refusal is not the only way in. + TableHeader.extend({ content: CELL_CONTENT }), + TableCell.extend({ content: CELL_CONTENT }), TableColumnResize, TableRowResize, // "Split Cells…" (N×M); merge uses extension-table's built-in mergeCells. diff --git a/src/lib/editor/extensions/CLAUDE.md b/src/lib/editor/extensions/CLAUDE.md index d4d7be7..3fb975a 100644 --- a/src/lib/editor/extensions/CLAUDE.md +++ b/src/lib/editor/extensions/CLAUDE.md @@ -56,5 +56,9 @@ unloaded) and supplies the custom table view + drag plugins instead. **What a command refuses, and why** (found by `tests/monkey/`): no note in a text box and no text box in a note (neither format holds one there); no table in a table, a list item or a note (nothing exports a nested one, ODF's list item holds no table, and ProseMirror would -wrap one in a text box to fit a note's inline content); a selection stops at the notes' -boundary (`clampToSide`); the notes plugin's repairs ride the history event they follow. +wrap one in a text box to fit a note's inline content) — and a **cell's content expression** +says the same (`CELL_CONTENT`, `extensions.ts`), since a command's refusal is not the only +way one gets in; a selection stops at the notes' boundary (`clampToSide`); the notes +plugin's repairs ride the history event they follow. +Blocks wrapped into a columns section lose a manual page break: pagination breaks a +fragment as a whole, so nothing there honours one — and only DOCX could write it. diff --git a/src/lib/editor/extensions/columns.ts b/src/lib/editor/extensions/columns.ts index dbea032..07cf639 100644 --- a/src/lib/editor/extensions/columns.ts +++ b/src/lib/editor/extensions/columns.ts @@ -100,6 +100,12 @@ function collectWrappable(state: EditorState, start: number, end: number): { blo return ok && blocks.length ? { blocks: mergeJoinedBlocks(blocks), from, to } : null; } +// Pagination breaks a columns fragment as a whole, so a manual page break inside one is +// honoured by nothing — the blocks lose theirs on the way in. +function withoutPageBreaks(blocks: PMNode[]): PMNode[] { + return blocks.map((b) => (b.attrs?.breakBefore ? b.type.create({ ...b.attrs, breakBefore: null }, b.content, b.marks) : b)); +} + function childArray(n: PMNode): PMNode[] { const out: PMNode[] = []; n.forEach((c) => out.push(c)); @@ -146,7 +152,7 @@ function applyToWholeDoc(state: EditorState, tr: Transaction, attrs: ColumnsAttr if (run) runs.push(run); if (!runs.length) return false; for (const r of [...runs].reverse()) { - tr.replaceWith(r.from, r.to, type.create(attrs, mergeJoinedBlocks(r.blocks))); + tr.replaceWith(r.from, r.to, type.create(attrs, withoutPageBreaks(mergeJoinedBlocks(r.blocks)))); } return true; } @@ -261,7 +267,7 @@ export const Columns = Node.create({ const covered = collectWrappable(state, $from.before(1), $to.after(1)); if (!covered) return false; if (dispatch) { - const tr = state.tr.replaceWith(covered.from, covered.to, type.create(attrs, covered.blocks)); + const tr = state.tr.replaceWith(covered.from, covered.to, type.create(attrs, withoutPageBreaks(covered.blocks))); tr.setSelection(TextSelection.near(tr.doc.resolve(covered.from + 1))); dispatch(tr.scrollIntoView()); } diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index 512c1c1..e6f0e20 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -45,6 +45,7 @@ fallback download shows a one-time hint (`edentext-download-hint`) that the brow - **A text box's own parts, DOCX** (`applyTextBoxesDocx`): the `docx` package can't emit a DrawingML shape, so the box XML is a hand-built string — and its content needs parts the package never hears about. The pass holds the whole unzipped package, so it mints them itself: a picture becomes `word/media/tbx{n}.{ext}` plus a `Relationship` above the package's highest `rId` (Content_Types already declares the raster defaults), a list an ``/`` pair above its highest id, appended **before** the first `` because every abstract definition has to precede them. The numbering ids only exist once the packed file is in hand, so the paragraphs carry a `TXBX_NUM` sentinel that the same pass substitutes. - **Templates** (`template.ts`): a `.ott`/`.dotx` is the built document with one label changed — ODF's package media type (in `mimetype` **and** in the manifest, the mimetype entry staying first and stored) and OOXML's content type for `/word/document.xml`. The template's format follows the document's (`handleSaveTemplate`). - **Formulas** (`replaceFormulas`/`applyFormulas`): an `MTH`-sentinel run becomes an as-char `` plus a `Formula{n}/content.xml` sub-document (MathML root) and its two manifest entries. **No `svg:width`/`svg:height`** — a sized frame makes LibreOffice scale the object instead of typesetting it. DOCX emits `` (or `` when displayed) from the same post-pack pass as the text boxes. See `docs/architecture/formulas.md`. +- **`applyEndnoteImagesDocx`** — the `docx` package registers a footnote's pictures in `word/_rels/footnotes.xml.rels` and leaves the endnotes' `rId{file}` placeholders standing, so an endnote's picture pointed at a relationship no reader could follow. The pass mints the relationship in that part's rels and fills the id in. - **Right-to-left pages**: ODF gets `style:writing-mode="rl-tb"` on the page layout (`rewriteStylesXml`), DOCX a `` in every `w:sectPr` from a post-pack pass (`applyBidiDocx`) — the package exposes only the paragraph-level flag. A **paragraph's** own direction (`textDirection.ts`) rides that same flag in DOCX and the `ParaStyle`/PBX-spec pipe in ODF, so it reaches cells and list items as well as top-level blocks. - **Mirror margins** (`applyMirrorMarginsDocx`, `export/docx.ts`): the `docx` package writes only the per-section `w:pgMar`, so Word's document-level `w:mirrorMargins` goes into `word/settings.xml` in the same post-pack pass; ODF's flag rides `rewriteStylesXml`. Both formats already hold left/right as the inner/outer pair, so only the flag has to travel. - **Bookmarks & cross-references** (`replaceBookmarks`/`applyBookmarks`): the `BMS`/`BME`/`XRF` sentinels are spliced into the **run text** rather than emitted by a run builder, so they ride every odf-kit path (cells, custom paragraphs, lists) with no per-emitter change; one post-pass rewrites them to ``/``/``. DOCX needs no pass at all — the `docx` package has `Bookmark`, `SimpleField` (`REF`/`PAGEREF`) and `InternalHyperlink`. See `docs/architecture/formatting.md`. @@ -54,6 +55,8 @@ fallback download shows a one-time hint (`edentext-download-hint`) that the brow - **Bibliography** (`replaceBibEntries`/`applyBibEntries`, `applyBibliographyDocx`): a `BIB` sentinel becomes a `` whose attributes are the whole source record and which **wraps** the text the citation shows (unlike an index mark, which is a point). The `` index writes an entry template per type actually cited, spelling out the same "key: author, title, year" the node view prints — LibreOffice regenerates the rows from it, so a template that said anything else would rewrite them. DOCX cites by tag (`CITATION "key"`) and keeps the sources in a **custom-XML part** the `docx` package knows nothing about, so the post-pack pass mints all four pieces itself: `customXml/item1.xml` (`b:Sources`), its `itemProps1.xml`, both relationships and the properties part's content type. (Probed: LibreOffice's own DOCX export writes the `CITATION` field but no sources at all.) - **Ruby** (`replaceRuby`/`applyRuby`, `applyRubyDocx`): an `RBY` sentinel becomes a `` holding both halves plus one minted `style:family="ruby"` automatic style (centred, above — probed: LibreOffice writes that style straight back out, adding only its own `loext:` mirror of the position). Word's is a `` run the `docx` package cannot emit, so a post-pack pass swaps the sentinel run for it; the `w:rubyPr` sizes are half-points of the 12pt body default, which is what LibreOffice's own export writes. - **Cell formulas** (`applyCellFormulas`, `formulaCellContent`): ODF puts the formula on the **cell**, so the pass walks ``. They are re-emitted at the item's end (after a nested list, which is where they usually belong) wearing the item's own paragraph style. DOCX needs none of it: `listToParagraphs` writes the extras as unnumbered paragraphs at the item's indent, which is Word's own shape. +- **`applyTableHeaderRows`** — `` around the first row when the table asks for the repeat **or** that row's cells are header cells: ODF spells both with the one element, as `w:tblHeader` does, so a header row reads back as one (and as repeating, in both formats). - **`applyListItemStyles`** — per-item alignment/spacing/line-height for top-level lists (odf-kit's ListBuilder has no per-item options) via minted automatic styles. - **`applyListIndents`** — a top-level list's whole-list `indent` (cm) added to its `L#` list-style per-level `fo:margin-left`/`text:list-tab-stop-position` (label-alignment mode ignores the paragraph margin, so the shift must live in the list style). Import reverses it in `convertList` (level-1 margin minus the 1.27cm base). - **`applyNestedListTypes`** — odf-kit emits nested lists as bare `` sharing the top-level list style, so a nested list of a different kind/format (e.g. ordered inside bullets) gets its own minted 6-level list style. diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index cbbe11c..1953325 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -1857,6 +1857,35 @@ function applyMirrorMarginsDocx(bytes: Uint8Array): Uint8Array { return zipSync(out); } +// Post-pack pass: the package registers a footnote's pictures in +// word/_rels/footnotes.xml.rels and leaves the endnotes' `rId{file}` placeholders +// unresolved — a drawing no reader can follow. Mint the relationship and fill them in. +function applyEndnoteImagesDocx(bytes: Uint8Array): Uint8Array { + const files = unzipSync(bytes); + const xmlBytes = files['word/endnotes.xml']; + const xml = xmlBytes ? strFromU8(xmlBytes) : ''; + if (!xml.includes('rId{')) return bytes; + const relsPath = 'word/_rels/endnotes.xml.rels'; + const rels = (files[relsPath] ? strFromU8(files[relsPath]) : ``) + .replace(/]*)\/>/, ''); + let next = maxIdIn(rels, /Id="rId(\d+)"/g) + 1; + const ids = new Map(); + const added: string[] = []; + files['word/endnotes.xml'] = strToU8(xml.replace(/rId\{([^}]+)\}/g, (_m, name: string) => { + let id = ids.get(name); + if (!id) { + id = `rId${next++}`; + ids.set(name, id); + added.push(``); + } + return id; + })); + files[relsPath] = strToU8(rels.replace('', added.join('') + '')); + const out: Record = {}; + for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 6 }]; + return zipSync(out); +} + // Post-pack pass: the page's own decoration. Word keeps the background on w:document // (switched on in settings.xml), the border in every w:sectPr, and the watermark as a // VML fontwork shape in each header part — the shapes LibreOffice writes, probed. @@ -3031,7 +3060,7 @@ export async function buildDocx( // The note configuration goes out whether or not a note exists yet, as Word keeps its // own in settings.xml — a document numbering its first footnote from 3 must still say so. const withNotePr = applyNotePrDocx(cited, notesSettings); - const withNotes = docNoteIds.size ? applyNoteMarksDocx(withNotePr) : withNotePr; + const withNotes = docNoteIds.size ? applyEndnoteImagesDocx(applyNoteMarksDocx(withNotePr)) : withNotePr; const threaded = applyCommentsExtendedDocx(withNotes); const mirrored = margins.mirrored ? applyMirrorMarginsDocx(threaded) : threaded; const bidi = applyNoHyphensDocx(rtl ? applyBidiDocx(mirrored) : mirrored); diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 062082a..a6b95ab 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -390,6 +390,23 @@ function replacePageBreaks(doc: TiptapNode): TiptapNode { }; } +// odf-kit writes a list item's first paragraph and nothing else, so the item's further +// blocks ride it SEG-separated; applyListItemBlocks re-emits them as their own. +function mergeListItemBlocks(node: TiptapNode): TiptapNode { + if (!node.content?.length) return node; + const kids = node.content; + if (node.type === 'listItem' && kids[0]?.type === 'paragraph') { + const extras = kids.slice(1).filter(b => b.type === 'paragraph' || b.type === 'heading'); + if (extras.length) { + const first = { ...kids[0], content: [...(kids[0].content ?? []), + ...extras.flatMap(e => [{ type: 'text', text: SEG }, ...(e.content ?? [])])] }; + const rest = kids.slice(1).filter(b => b.type !== 'paragraph' && b.type !== 'heading'); + return { ...node, content: [first, ...rest.map(mergeListItemBlocks)] }; + } + } + return { ...node, content: kids.map(mergeListItemBlocks) }; +} + // Mark the first block of each section after the first, so applySectionMasterPages can // point it at that section's master page. Runs after the column hoist, so a section // starting inside one is top-level by now; the PGB marker keeps its place at the front. @@ -1525,6 +1542,48 @@ function odfLookAttrs(look: TableLook): string { // odf-kit's ListBuilder has no per-item paragraph options, so list-item paragraphs all // emit as List_20_Bullet/Number. Rewrite content.xml to point those at automatic styles // that inherit the list style and add fo:text-align / fo:margin-top / fo:margin-bottom. +// Split a list item's paragraph back into the blocks mergeListItemBlocks merged into it. +// ponytail: they are re-emitted at the item's end, so a block before a nested list lands +// after it — and each takes the item's own paragraph style. +function applyListItemBlocks(odtBytes: Uint8Array): Uint8Array { + const files = unzipSync(odtBytes); + const contentBytes = files['content.xml']; + if (!contentBytes) return odtBytes; + const content = strFromU8(contentBytes); + let out = ''; + let i = 0; + let touched = false; + for (;;) { + const at = content.indexOf('', pStart); + const pEnd = gt < 0 ? -1 : content.indexOf('', gt); + if (pEnd < 0) { out += content.slice(i); break; } + const inner = content.slice(gt + 1, pEnd); + if (!inner.includes(SEG)) { out += content.slice(i, pEnd); i = pEnd; continue; } + // The item's own end, past every nested item. + let depth = 1; + let scan = pEnd; + while (depth > 0) { + const open = content.indexOf('', scan + 1); + if (close < 0) break; + if (open >= 0 && open < close) { depth++; scan = open; } else { depth--; scan = close; } + } + const [first, ...extras] = inner.split(SEG); + const attrs = content.slice(pStart + 7, gt); + out += content.slice(i, gt + 1) + first + '' + + content.slice(pEnd + 9, scan) + + extras.map(e => `${e}`).join(''); + i = scan; + touched = true; + } + if (!touched) return odtBytes; + files['content.xml'] = strToU8(out); + return zipSync(files, { level: 6 }); +} + function applyListItemStyles(odtBytes: Uint8Array, styles: ParaStyle[]): Uint8Array { if (styles.every(paraStyleIsEmpty)) return odtBytes; @@ -3725,7 +3784,11 @@ function tablePropsOf(node: TiptapNode, contentWidthCm: number): TableProps | nu const mt = Math.max(0, Number(node.attrs?.marginTop) || 0); const mb = Math.max(0, Number(node.attrs?.marginBottom) || 0); const keepRows = node.attrs?.keepRows === true; - const repeatHeader = node.attrs?.repeatHeader === true; + // A header row is one whether the table asked for the repeat or only the row's own + // cells say they head it — ODF spells both with the one element (docx.ts does the same). + const firstRow = (node.content ?? []).find(r => r.type === 'tableRow'); + const repeatHeader = node.attrs?.repeatHeader === true + || (firstRow?.content ?? []).some(c => c.type === 'tableHeader'); if (ml + mr > contentWidthCm - 1) ml = mr = 0; if (!ml && !mr && !mt && !mb && !keepRows && !repeatHeader) return null; return { ml: round3(ml), mr: round3(mr), mt: round3(mt), mb: round3(mb), keepRows, repeatHeader }; @@ -4880,7 +4943,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU const bibMarks: BibExport[] = []; const rubies: RubyExport[] = []; const sentinels = replaceRuby(replaceBibEntries(replaceIndexEntries(replaceRevisions(replaceSequenceFields(replaceComments(replaceBookmarks(replaceFormulas(replacePlaceholderFields(replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceSectionBreaks(replaceNotes(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns), notes)))), images), dateFields), placeholderLabels), formulas), crossRefs), commentList), seqFields), revisionList), indexMarks), bibMarks), rubies); - const raw = markTextEffects(bakeListCharStyles(sentinels, styles), DEFAULT_FONT_SIZE_PT, styles); + const raw = mergeListItemBlocks(markTextEffects(bakeListCharStyles(sentinels, styles), DEFAULT_FONT_SIZE_PT, styles)); let headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null; let footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null; // Different first page (ODF header-first): page 1 gets its own zone content. @@ -5071,7 +5134,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU const listStyles: ParaStyle[] = []; collectListItemStyles(raw, listStyles); - const styledLists = applyListItemStyles(numberedOdt, listStyles); + const styledLists = applyListItemBlocks(applyListItemStyles(numberedOdt, listStyles)); // Whole-list indent → added to each L# list-style's level margins. const listLevels: ListLevelProps[][] = []; diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index 0299613..346c100 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -36,6 +36,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt( - **The document's page setup is the first section's** — zones, margins, paper and edge distances of the master its leading block uses (`masterPagesOf`: `Standard` where it names none, and a table or list at the top starts on Standard too); a later section carries its own where they differ (`hfSetOfMasterPage`), so a chapter master's setup is kept as the section's. Blocks measure against their *own* section's text width (`Ctx.contentWidthCm`, moved on at every master switch — a table's margins, an inline image's fit, the tab-stop suppression; measured: 17.0cm against a real 14.7 on one file), an index's stop against the document's as well, where the export keeps it. **Naming a master breaks the page** (LibreOffice's "page break with page style") — probed: even a style naming the master the page already uses breaks, which is how a book starts each chapter fresh, so only the document's first block is exempt. The *section* (its own header/footer set) starts only where the master really changes. An empty `style:master-page-name=""` is LibreOffice's explicit "no change" and ends the parent-chain walk, so an ancestor's master can't break every child style. `fo:break-after="page"` becomes the next block's `breakBefore`, the only break the editor stores. A master with a `style:next-style-name` is itself the different-first-page flag: its own (possibly absent) zones govern page one — **unless the two name each other**. A `next-style-name` cycle is LibreOffice's Left Page / Right Page, one mirrored setup rather than a hand-over: the right master's geometry governs, the left master's zones become the even variant, and `style:page-usage="left"`/`"right"` marks the margins `mirrored` **only where that twin really exists** — on a single-sided layout it means a page that starts on the right (`HfSet.startsOn`, which takes a blank page rather than the pair), and swapping its pair drew an index 9mm off. Reading it as a hand-over cost a book-style body its running head on every second page and its inner/outer margins with it. - **A page-anchored frame** (`text:anchor-type="page"`) is out of the flow: it rides a paragraph of its own that collapses to nothing, and the node view places it from its page's corner (`anchorPage`, `image.ts`) — that paragraph must stay `position: static` (`editor.css`, `!important` over the line-height rule's own `position: relative`), or it becomes the frame's containing block instead of the page and offsets it by a page margin. Behind the text by default (`style:run-through="background"`); a cover page's own graphic instead declares `"foreground"` (`inFront` attr) to sit in front and hide what the page hoists behind it (a redundant logo, a title paragraph the graphic already carries). In a cell it falls back to an ordinary floating frame. - **A header/footer zone is fitted to its schema** (`fitZoneSchema`): the zone editor has fewer marks and nodes than the body, and one it doesn't know makes `generateHTML` throw — which renders the whole zone blank. Unknown marks are dropped, unknown nodes leave their text. +- **A band-wrapped DOCX frame flush left is at no side of its own** (`anchorWrap`): the exporter writes exactly that — `wp:align` left with `allowOverlap="0"` — for a frame with no `wrapAlign`, and reserves the overlap for the side-by-side pair a real left/right band frame makes. Read as an alignment, a full-width figure came back floating. - **An as-char text box is placed by its paragraph**, which is where its alignment stays — the box keeps only the `style:horizontal-pos` a band-wrapped frame declares of its own. Its `fo:padding` is read too — the editor's own 0.15cm ring is only the default. - **A cell's number format** is on its *style* in ODF (`style:data-style-name` → a `number:number-style`/`percentage-style`/`currency-style`/`date-style`, read for its family, decimals and grouping) and on the field in DOCX (the `\#` picture, or `\@` for a date, which is not part of the formula and is cut out of it). Only a formula cell keeps one, and the file's own currency symbol and date order give way to the document language's. - **Vertical text in a box** is the style's writing mode in ODF (a frame's graphic properties — where LibreOffice writes it — or a drawing shape's paragraph properties; both top-to-bottom modes) and `w:bodyPr vert` in DOCX (`vert`/`eaVert`/`mongolianVert`/`wordArtVert`; `vert270` reads bottom-to-top, which the browser cannot lay out, so it is not one). VML says the same as `layout-flow:vertical` on the textbox. diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 8c3a743..59035ec 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -1384,10 +1384,13 @@ function stripPlaceholderGray(marks: Mark[]): Mark[] { function noteRefNode(wid: string | null, kind: NoteKind, ctx: Ctx, baseRun: RunProps, defaults: BlockDefaults, label: string | null = null): Node | null { const note = wid == null ? null : ctx.noteParts[kind].get(wid); if (!note) return null; + // A picture or a link in a note is related from the note's own part, not the document's. + const noteRels = ctx.files[`word/_rels/${kind}s.xml.rels`]; + const noteCtx: Ctx = noteRels ? { ...ctx, rels: parseRels(noteRels) } : ctx; const content: Node[] = []; for (const para of Array.from(note.children)) { if (para.namespaceURI !== W || para.localName !== 'p') continue; - const runs = convertInline(para, ctx, baseRun, defaults, false); + const runs = convertInline(para, noteCtx, baseRun, defaults, false); if (content.length && runs.length) content.push({ type: 'hardBreak' }); content.push(...runs); } @@ -2134,8 +2137,11 @@ function anchorWrap(anchor: Element, ctx: Ctx): { wrap: 'left' | 'right' | 'topB const offsetCm = anchorOffsetX(anchor, ctx); const align = anchor.getElementsByTagNameNS(WP, 'positionH')[0] ?.getElementsByTagNameNS(WP, 'align')[0]?.textContent?.trim(); + // A band frame with no side of its own is written flush left with overlap forbidden; + // that pair is "no side", not a left-aligned frame (which shares its band). + const bandLeft = (align === 'left' || align === 'inside') && anchor.getAttribute('allowOverlap') === '0'; const alignH: 'left' | 'right' | null = align === 'right' || align === 'outside' ? 'right' - : align === 'left' || align === 'inside' ? 'left' : null; + : (align === 'left' || align === 'inside') && !bandLeft ? 'left' : null; // Only the side the text flows on: the gap on the other one is the frame's offset. const distOf = (wrap: string) => { const emu = parseInt(anchor.getAttribute(wrap === 'right' ? 'distL' : 'distR') ?? '', 10); diff --git a/tests/unit/list-item-blocks.test.ts b/tests/unit/list-item-blocks.test.ts new file mode 100644 index 0000000..4debc48 --- /dev/null +++ b/tests/unit/list-item-blocks.test.ts @@ -0,0 +1,35 @@ +// A list item may hold more than one block. odf-kit writes only its first paragraph, so +// the export merges the rest into it (SEG) and splits them out again — this is the guard. +import { describe, it, expect } from 'vitest'; +import { unzipSync, strFromU8 } from 'fflate'; +import { buildOdt } from '../../src/lib/export/odt'; +import { buildDocx } from '../../src/lib/export/docx'; +import { importOdt } from '../../src/lib/import/odt'; +import { hasXmllint, validateOdt } from '../schemaValidate'; + +type N = any; +const p = (t: string) => ({ type: 'paragraph', content: [{ type: 'text', text: t }] }); +const list = (item: N[]) => ({ type: 'bulletList', content: [{ type: 'listItem', content: item }] }); +const doc = (content: N[]) => ({ type: 'doc', content }) as N; +const shape = (n: N): string => n.type + (n.content ? `(${n.content.map(shape).join(',')})` : ''); +const nested = list([p('sub')]); + +describe('a list item holding more than one block', () => { + it('round-trips a second paragraph', async () => { + const odt = await buildOdt(doc([list([p('one'), p('two')])])); + if (hasXmllint) expect(validateOdt(unzipSync(odt))).toEqual([]); + expect(shape(importOdt(odt).content)).toBe('doc(bulletList(listItem(paragraph(text),paragraph(text))))'); + }); + + it('keeps a paragraph that follows a nested list, in its place', async () => { + const odt = await buildOdt(doc([list([p('one'), nested, p('after')])])); + if (hasXmllint) expect(validateOdt(unzipSync(odt))).toEqual([]); + expect(shape(importOdt(odt).content)) + .toBe('doc(bulletList(listItem(paragraph(text),bulletList(listItem(paragraph(text))),paragraph(text))))'); + }); + + it('writes the further blocks to DOCX too', async () => { + const docx = await buildDocx(doc([list([p('one'), p('two')])])); + expect(strFromU8(unzipSync(docx)['word/document.xml'])).toContain('two'); + }); +}); diff --git a/tests/unit/note-image.test.ts b/tests/unit/note-image.test.ts new file mode 100644 index 0000000..1a54e62 --- /dev/null +++ b/tests/unit/note-image.test.ts @@ -0,0 +1,34 @@ +// A picture inside a note: the docx package writes the drawing into footnotes.xml and +// endnotes.xml but registers the media for the footnotes part only. +import { describe, it, expect } from 'vitest'; +import { unzipSync, strFromU8 } from 'fflate'; +import { buildDocx } from '../../src/lib/export/docx'; +import { importDocx } from '../../src/lib/import/docx'; + +const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; +const img = { type: 'image', attrs: { src: PNG, width: 40, height: 30 } }; +const doc = (kind: 'footnote' | 'endnote') => ({ + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'a' }, { type: 'noteRef', attrs: { id: 'n1', kind, text: '1' } }] }, + { type: 'noteSection', content: [{ type: 'note', attrs: { id: 'n1', kind, text: '1' }, content: [{ type: 'text', text: 'x' }, img] }] }, + ], +}) as any; + +describe.each(['footnote', 'endnote'] as const)('a picture in a %s', (kind) => { + it('is written with a relationship of its own', async () => { + const part = `word/${kind}s.xml`; + const files = unzipSync(await buildDocx(doc(kind))); + const rels = strFromU8(files[`word/_rels/${kind}s.xml.rels`] ?? new Uint8Array()); + const id = /r:embed="([^"]+)"/.exec(strFromU8(files[part]))?.[1] ?? ''; + expect(id).toMatch(/^rId\d+$/); + expect(rels).toContain(`Id="${id}"`); + expect(rels).toContain('media/'); + }); + + it('reads back as the picture, not the placeholder', async () => { + const bytes = await buildDocx(doc(kind)); + const back = JSON.stringify(importDocx(bytes).content); + expect(back).toContain('data:image/png'); + }); +});