fix: a list item's further blocks, a header row and a note's picture survive the save

An .odt kept only the first paragraph of a list item; the rest ride it SEG-separated and
are split back out inside the same item. A header row the table did not ask to repeat
came back as ordinary cells, and only DOCX wrote it. An endnote's picture pointed at a
relationship the package never minted, and neither note's pictures were read through
their own part's relationships. A frame band-wrapped flush left is at no side of its own.
Blocks wrapped into columns drop a page break nothing there honours, and a cell's content
expression now says what both exporters can write into one.
This commit is contained in:
Steffen Becker committed 2026-09-06 19:07:16 +02:00
1 parent d156482a3f
commit 76c9564028
19 files changed
+198 -13

No files matched your search

+1 -1
View File
@@ -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 Line truncated
- **`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 oneLine truncated
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6 -2
View File
@@ -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.
+6 -2
View File
@@ -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.
+8 -2
View File
@@ -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());
}
+3
View File
@@ -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 `<w:abstractNum>`/`<w:num>` pair above its highest id, appended **before** the first `<w:num>` 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 `<draw:frame><draw:object xlink:href="./Formula{n}"/>` 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 `<m:oMath>` (or `<m:oMathPara>` 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 `<w:bidi/>` 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 `<text:bookmark-start/>`/`<text:bookmark-end/>`/`<text:bookmark-ref>`. 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 `<text:bibliography-mark>` whose attributes are the whole source record and which **wraps** the text the citation shows (unlike an index mark, which is a point). The `<text:bibliography>` 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 `<text:ruby>` 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 `<w:ruby>` 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 `<table:table-cell` in the same document order `applyCellBlocks` does and writes `table:formula="ooow:…"` + the cached `office:value`. The `ooow:` is a namespace prefix, so `ensureOoowNamespace` declares it — undeclared, LibreOffice reads it as part of the formula. Word instead keeps a field *inside* the cell, so the DOCX side swaps the cell's first paragraph for one `SimpleField` — and the cell's **number format** rides that field's `\#` switch (`\@` for a date), where ODF puts it on the cell style (a minted data style plus a copy of the shared cell style that references it). The data style names the document's language, or LibreOffice prints its own locale's separators and currency symbol. The `\#` picture is written with that language's separators too (`cellFormatCode`): Word reads a picture with the reader's regional settings, so a German Word takes `"0.00"`'s dot as grouping and prints 84 as "084"; the importer reads a picture back by shape, whichever locale wrote it. See `docs/architecture/tables.md`.
- **`mergeListItemBlocks`/`applyListItemBlocks`** — odf-kit writes a list item's **first paragraph only**, so the item's further blocks ride it `SEG`-separated and the pass splits them back out inside the same `<text:list-item>`. 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`** — `<table:table-header-rows>` 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 `<text:list>` 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.
+30 -1
View File
@@ -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]) : `<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>`)
.replace(/<Relationships([^>]*)\/>/, '<Relationships$1></Relationships>');
let next = maxIdIn(rels, /Id="rId(\d+)"/g) + 1;
const ids = new Map<string, string>();
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(`<Relationship Id="${id}" Type="${R_NS}/image" Target="media/${name}"/>`);
}
return id;
}));
files[relsPath] = strToU8(rels.replace('</Relationships>', added.join('') + '</Relationships>'));
const out: Record<string, [Uint8Array, { level: 6 }]> = {};
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);
+66 -3
View File
@@ -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('<text:list-item', i);
if (at < 0) { out += content.slice(i); break; }
const pStart = content.indexOf('<text:p', at);
const gt = pStart < 0 ? -1 : content.indexOf('>', pStart);
const pEnd = gt < 0 ? -1 : content.indexOf('</text:p>', 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('<text:list-item', scan + 1);
const close = content.indexOf('</text:list-item>', 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 + '</text:p>'
+ content.slice(pEnd + 9, scan)
+ extras.map(e => `<text:p${attrs}>${e}</text:p>`).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[][] = [];
+1
View File
@@ -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.
+8 -2
View File
@@ -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);
+35
View File
@@ -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');
});
});
+34
View File
@@ -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');
});
});