feat: mirrored page margins, so an even page is the left-hand one
ODF style:page-usage="mirrored" and Word's w:mirrorMargins make the declared left/right the inner/outer pair, which an even page swaps. Read, rendered and written back in both formats; the body rides the per-block inset a section's own margins already use, the header and footer band moves with it. Measured on a mirrored two-column file: its page 2 went from 12.7-184.1mm to 31.8-203.2, which is LibreOffice's to 0.1mm, and its running head with it.
This commit is contained in:
1 parent
b35551acd0
commit
096de84b82
13 files changed
+146
-13
No files matched your search
+1
-1
@@ -72,6 +72,7 @@
|
||||
**Page & layout**
|
||||
- Page margins (cm) and page orientation (portrait / landscape)
|
||||
- Page format picker (Layout panel): 15 Word/LibreOffice sizes — A3–A6, ISO B4–B6, JIS B4/B5, Letter, Legal, Tabloid, Executive, Folio, Statement — each showing its cm dimensions; drives the on-screen page, pagination, and ODT/DOCX/PDF export, and is detected & adopted on import
|
||||
- Mirrored page margins (ODF `style:page-usage="mirrored"`, Word's `w:mirrorMargins`): the declared left/right are the inner/outer pair, and an even (left-hand) page swaps them — body text and the header/footer band alike. Round-trips through both formats
|
||||
- Multi-column (newspaper) layout: 1–3 columns with adjustable gap, applied to the whole document (no selection) or to selected paragraphs, Word-style — text fills column 1 to the page bottom, then column 2, and flows across pages with mid-paragraph line breaks; a section followed by other content balances its columns. Round-trips to ODF `text:section`/`style:columns` and DOCX continuous sections with `w:cols`
|
||||
- Page margins per section: a section's own `w:pgMar` / ODF page layout is read, rendered and written back, and an ODF page style that hands over to another (`style:next-style-name`, the title-page idiom) gives its layout to the section's first page and the successor's to the rest. The ruler and the header/footer layer still show the document's own pair
|
||||
- Headers & footers: page-number / page-count fields, configurable edge distances, and variants — different first page and different odd & even pages (edit a page's zone directly; blank first/even zones supported). Press Enter to add blank lines that grow a zone into the page while body text reflows so the page break sits above the footer / below the header, never overlapping. Inline images/logos can be inserted in a zone. Round-trips to ODF (`style:header-first`/`-left`, `draw:frame`) and DOCX (`w:titlePg`/`w:evenAndOddHeaders`, `ImageRun`)
|
||||
@@ -127,7 +128,6 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08.
|
||||
- Alphabetical index and bibliography
|
||||
- Line numbering (LibreOffice Tools ▸ Line numbering / Word Layout ▸ Line numbers)
|
||||
- Section-level page **size and orientation**: only the margins are per section. A file whose sections disagree on the page size keeps the last one's
|
||||
- Mirrored page margins (ODF `style:page-usage="mirrored"`, Word's mirror margins): the inner and outer margins are drawn as left and right on every page, so an even page's text block sits where an odd page's does (measured 19 mm on a two-column scripture extract)
|
||||
- Table extras: repeat the header row on every page, keep a row from splitting across pages, sort, sum/formula in a cell, number recognition
|
||||
- A list level's own hanging indent: the marker sits at the 0.635 cm both exports write (`LIST_HANGING_CM`), so a wider *left*-set marker overflows where Word moves the text to the next list tab (a **right**-set one — `w:lvlJc`, which is what the built-in Roman numberings use — grows into the margin and is fine). Reading the value back naively also moves the markers of our own ODT exports — LibreOffice draws its own flat hanging at exactly the value odf-kit writes for level 2; see `tests/render-parity/README.md` before building on it
|
||||
- Images live as data-URIs in the autosaved JSON, so an image-heavy document can exceed the ~5 MB localStorage quota; `storage/autosave.ts` warns once and a reload restores the last version that still fit. IndexedDB (as `embeddedFontStore.ts` already uses) would lift the ceiling
|
||||
|
||||
@@ -13,6 +13,8 @@ A custom ProseMirror plugin that simulates paginated A4 layout entirely in CSS/D
|
||||
4. Sets `minHeight` on the `.tiptap` element so the visual page slots always exist.
|
||||
5. Fires a `pm-pagecount` CustomEvent (bubbles to `Editor.svelte`) carrying `numPages`, `docHeight`, and `tableBreakBands`.
|
||||
|
||||
**Mirrored margins** ride the same per-block inset as a section's own side margins: `.tiptap`'s padding draws the odd page's pair, so a leaf landing on an even page is inset by `--user-margin-mirror` (right − left) one way and the other (`storage/pageMargins.ts`), and the header/footer band moves with it in `HeaderFooterLayer.svelte`. A block straddling a page boundary takes the inset of the page it starts on, as a section's own margins already do.
|
||||
|
||||
**Widow-orphan control:** `findLineSplit` picks the first line that overflows the page, then applies Word's/LibreOffice's rule (`MIN_KEPT_LINES` = 2, on by default in both via OOXML `w:widowControl`, so not configurable): fewer than two lines carrying over pulls one more down with them, and fewer than two staying behind cancels the split so the caller pushes the whole block. A leaf taller than one page slot passes `minLines: 1` — there the rule can't be satisfied and splitting beats overflowing.
|
||||
|
||||
**Images in a paragraph:** an as-character image is a line box like any other (`getLineRects`
|
||||
@@ -88,4 +90,4 @@ A break *between rows* instead closes the table on both sides of the gap: collap
|
||||
|
||||
## 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). 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. **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. `merLine 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). 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 Line truncated
|
||||
@@ -77,7 +77,8 @@
|
||||
let pages = $derived(Array.from({ length: Math.max(1, numPages) }, (_, i) => i + 1));
|
||||
|
||||
function zoneBox(zone: HfZone, page: number) {
|
||||
const left = mLeft;
|
||||
// Mirrored margins: an even page is the left-hand one, so the pair is swapped.
|
||||
const left = pageMargins.mirrored && page % 2 === 0 ? mRight : mLeft;
|
||||
const width = contentWidth;
|
||||
if (zone === 'header') {
|
||||
const top = (page - 1) * cycle + headerDistPx;
|
||||
|
||||
@@ -964,6 +964,10 @@ export const PageBreaks = Extension.create({
|
||||
.map((g) => g.split('|').map(Number))
|
||||
.filter((g) => g.length === 4 && g.every(Number.isFinite));
|
||||
const insetAt = (i: number) => inset[Math.min(i, inset.length - 1)] ?? [0, 0, 0, 0];
|
||||
// Mirrored margins (pageMargins.ts): the padding draws the odd page's pair,
|
||||
// so an even page's blocks move right by the difference between the two.
|
||||
const mirrorRaw = parseFloat(csRoot.getPropertyValue('--user-margin-mirror'));
|
||||
const mirror = Number.isFinite(mirrorRaw) ? mirrorRaw : 0;
|
||||
|
||||
const scale = getScaleFactor();
|
||||
const leaves = collectLeaves(CONTENT_HEIGHT, scale);
|
||||
@@ -1340,8 +1344,9 @@ export const PageBreaks = Extension.create({
|
||||
const landedPage = getPageForY(leaf.naturalTop + cumulativeShift, CYCLE_PX);
|
||||
if (leaf.sectionStart) sectionStartPages.push(landedPage);
|
||||
const ins = insetAt(sectionIndex);
|
||||
const insLeft = Math.round(landedPage === sectionFirstPage ? ins[0] : ins[2]);
|
||||
const insRight = Math.round(landedPage === sectionFirstPage ? ins[1] : ins[3]);
|
||||
const mir = landedPage % 2 === 0 ? mirror : 0;
|
||||
const insLeft = Math.round((landedPage === sectionFirstPage ? ins[0] : ins[2]) + mir);
|
||||
const insRight = Math.round((landedPage === sectionFirstPage ? ins[1] : ins[3]) - mir);
|
||||
if (insLeft || insRight) {
|
||||
// The leaf may be a row, or a line inside one; the inset belongs on the
|
||||
// top-level block, which is the ancestor .tiptap holds directly.
|
||||
|
||||
@@ -24,6 +24,7 @@ Frames, tables and pagination have their own deep-dives in `docs/architecture/`.
|
||||
- **Tables:** column widths come from the editor's per-column weights (`tableColumnWidthsCm`, summing exactly to the text width, with `table:align="margins"`). **Table margins:** a table's `marginLeft`/`marginRight` shrink that width and are written by `applyTableMargins` into the table's automatic style (`style:width` + `fo:margin-left`/`-right`; odf-kit names table styles `Table1`, `Table2`, … in document order, matching the descriptor list `exportTable` fills). odf-kit serializes each cell to a single `<text:p>` of runs; `exportTable` emits all of a cell's content into that paragraph separated by a `SEG` sentinel and records a `CellBlock[]` descriptor, then `applyCellBlocks` splits on `SEG` and rebuilds real `<text:h>`/`<text:p>`/`<text:list>` (incl. nested lists) with minted automatic paragraph styles. **Merged cells:** `exportTable` passes each cell's `colspan`/`rowspan` as odf-kit `CellOptions.colSpan`/`rowSpan`; odf-kit emits `table:number-columns/rows-spanned` and auto-generates the `<table:covered-table-cell>` placeholders (which never match the `applyCellBlocks` cell regex, so `cellBlocks` stays one-per-real-cell aligned). Import already reads spans/covered cells in `convertTable`. **Cell shading:** a cell's `backgroundColor` attr is passed as `CellOptions.backgroundColor` (normalized), which odf-kit mints into the cell style as `fo:background-color`. **Cell borders:** the per-side border attrs (tableCellBorders.ts) are passed as `CellOptions.borderTop/Right/Bottom/Left` (`'none'` or `'<W>pt solid #RRGGBB'`) overriding the table-level default border per side.
|
||||
- **Text boxes / shapes** (`replaceTextBoxes`/`applyTextBoxes`): a top-level `textBox` is **hoisted** — `replaceTextBoxes` (after `replacePageBreaks`, before the inline passes) swaps it for `TBX`-sentinel marker paragraphs (`S{i}`…`E{i}`, U+E008) bracketing its child blocks at top level, so every existing pass (custom attrs, list styles, inline sentinels, images) serializes them unchanged and document-order-indexed passes stay aligned. `applyTextBoxes` (after `applyImages`) wraps each serialized S…E region into `<draw:frame><draw:text-box fo:min-height>` (plain box; `svg:height` also emitted for non-auto-grow consumers) or `<draw:custom-shape>` + preset `<draw:enhanced-geometry>` (roundRect/ellipse, static LibreOffice-style paths) inside a fresh anchor paragraph, minting a `TbxFr{n}` graphic style (fill/stroke/padding + the image wrap props; `draw:auto-grow-height="true"` for boxes, both auto-grows explicitly **false** for shapes or LibreOffice's autofit shrinks them to their text).
|
||||
- **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`.
|
||||
- **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`.
|
||||
- **`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).
|
||||
@@ -31,7 +32,7 @@ Frames, tables and pagination have their own deep-dives in `docs/architecture/`.
|
||||
- **`applyListStartValues`** — odf-kit drops an ordered list's `start` attr; a Word list continued across an intervening paragraph (same numId, split into separate `orderedList` nodes with `start` > 1 by the importer) gets `text:start-value` on its first `<text:list-item>` so numbering keeps counting instead of restarting at 1.
|
||||
- **`applyTableRowHeights`** — dragged row heights → `style:min-row-height` automatic table-row styles.
|
||||
- **`collapseRunWhitespace`** — strips the bare `\n` separators odf-kit inserts between runs inside a paragraph (they'd otherwise collapse to spurious spaces mid-word).
|
||||
- **`rewriteStylesXml`** — default font Liberation Serif → Times New Roman, heading sizes/margins → the editor's values, Standard's `fo:margin-bottom` → 0 (odf-kit emits 0.212cm; every paragraph and list item inherits it, and the editor has no paragraph spacing — see below).
|
||||
- **`rewriteStylesXml`** — `style:page-usage="mirrored"` on the page layout when the margins mirror (the left/right pair is already the inner/outer one), default font Liberation Serif → Times New Roman, heading sizes/margins → the editor's values, Standard's `fo:margin-bottom` → 0 (odf-kit emits 0.212cm; every paragraph and list item inherits it, and the editor has no paragraph spacing — see below).
|
||||
- **`normalizeColor`** — coerces colors to `#RRGGBB` (ODF requirement; rejects/normalizes `rgb()` and short hex).
|
||||
|
||||
The filename is derived from the first non-empty heading (max 50 chars, sanitized), falling back to `document.odt`.
|
||||
|
||||
+19
-1
@@ -728,6 +728,23 @@ function applyFormulasDocx(bytes: Uint8Array, formulas: FormulaDocx[]): Uint8Arr
|
||||
return zipSync(out);
|
||||
}
|
||||
|
||||
// Post-pack pass: Word's mirror margins are a document setting (w:mirrorMargins in
|
||||
// word/settings.xml); the docx lib writes only the per-section w:pgMar, where left and
|
||||
// right are already the inner/outer pair the editor holds.
|
||||
function applyMirrorMarginsDocx(bytes: Uint8Array): Uint8Array {
|
||||
const files = unzipSync(bytes);
|
||||
const setBytes = files['word/settings.xml'];
|
||||
if (!setBytes) return bytes;
|
||||
const xml = strFromU8(setBytes);
|
||||
if (xml.includes('w:mirrorMargins')) return bytes;
|
||||
files['word/settings.xml'] = strToU8(
|
||||
xml.replace(/(<w:settings\b[^>]*>)/, '$1<w:mirrorMargins/>'),
|
||||
);
|
||||
const out: Record<string, [Uint8Array, { level: 6 }]> = {};
|
||||
for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 6 }];
|
||||
return zipSync(out);
|
||||
}
|
||||
|
||||
// ---- paragraphs ------------------------------------------------------------
|
||||
function alignOf(attrs: TiptapNode['attrs']): (typeof AlignmentType)[keyof typeof AlignmentType] | undefined {
|
||||
switch (attrs?.textAlign) {
|
||||
@@ -1309,5 +1326,6 @@ export async function buildDocx(
|
||||
});
|
||||
|
||||
const blob = await Packer.toBlob(doc);
|
||||
return applyFormulasDocx(applyTextBoxesDocx(new Uint8Array(await blob.arrayBuffer()), textBoxes), docFormulas);
|
||||
const packed = applyFormulasDocx(applyTextBoxesDocx(new Uint8Array(await blob.arrayBuffer()), textBoxes), docFormulas);
|
||||
return margins.mirrored ? applyMirrorMarginsDocx(packed) : packed;
|
||||
}
|
||||
@@ -2142,7 +2142,7 @@ function rezipOdt(files: Record<string, Uint8Array>): Uint8Array {
|
||||
// Rewrite styles.xml to match the editor's preview: default font Liberation Serif →
|
||||
// Times New Roman (metric-identical; see EXPORT_FONT), and Heading_20_1/2/3 sizes &
|
||||
// margins → the editor's values (odf-kit's defaults are larger; HEADING_STYLE_OVERRIDES).
|
||||
function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation, sheet: StyleSheet, used: Set<string>, usedTables: Set<string> = new Set(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM): Uint8Array {
|
||||
function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation, sheet: StyleSheet, used: Set<string>, usedTables: Set<string> = new Set(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, mirrored = false): Uint8Array {
|
||||
const files = unzipSync(odtBytes);
|
||||
const stylesBytes = files['styles.xml'];
|
||||
if (!stylesBytes) return odtBytes;
|
||||
@@ -2157,6 +2157,12 @@ function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; countr
|
||||
.replace(/fo:page-width="[^"]*"/, `fo:page-width="${round3(dims.w)}cm"`)
|
||||
.replace(/fo:page-height="[^"]*"/, `fo:page-height="${round3(dims.h)}cm"`);
|
||||
|
||||
// Mirrored margins: fo:margin-left/right are the inner/outer pair, which is what
|
||||
// the editor already holds, so only the flag has to be written back.
|
||||
if (mirrored) {
|
||||
styles = styles.replace(/<style:page-layout /, '<style:page-layout style:page-usage="mirrored" ');
|
||||
}
|
||||
|
||||
// Document spell-check language: set fo:language/fo:country on the base
|
||||
// Standard paragraph style, which every paragraph inherits from. LibreOffice
|
||||
// and Word read this as the document default language.
|
||||
@@ -3571,7 +3577,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
// Effects first: applyCharacterStyles then clones the style that already carries them.
|
||||
const withNamedStyles = applyCharacterStyles(applyTextEffects(applyParagraphStyles(withParaBoxes)));
|
||||
const usedTables = new Set(tableStyleNames.filter((t): t is TableStyleRef => !!t).map(t => t.name));
|
||||
const withStyles = rewriteStylesXml(withNamedStyles, language ?? null, pageFormat, orientation, styles, usedStyleNames(docJson, styles), usedTables, tabIntervalCm);
|
||||
const withStyles = rewriteStylesXml(withNamedStyles, language ?? null, pageFormat, orientation, styles, usedStyleNames(docJson, styles), usedTables, tabIntervalCm, margins.mirrored === true);
|
||||
const withHf = applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1, hfImages, evenHeaderPara, evenFooterPara);
|
||||
// Sections past the first get their own master page, which is where ODF keeps a
|
||||
// section's header/footer; the SEC-marked block points at it.
|
||||
|
||||
@@ -37,7 +37,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
|
||||
|
||||
## Header/footer import
|
||||
|
||||
- **Import:** `StyleResolver.masterPageHF()` reads `style:header`/`style:footer` + the header/footer-style heights; `pageGeometry()` reconstructs the body margin (page margin + zone height + spacing) and `edgeDistancesCm()` returns the raw page margins as the zone distances. The zone's gap to the body is laid out *inside* the band, not added to it, and `style:dynamic-spacing` changes neither side. The band is the declared height only as a **floor**: LibreOffice grows it to hold the zone's own lines, and a footer's first paragraph `fo:margin-top` rides the band with them — a header's `fo:margin-bottom` does not, it just hangs into the body (all probed). That growth is the runtime reach (`Editor.svelte`'s `hfReachPx`, which takes the biggest run's size as the line height and the zone paragraph's `spaceBefore`), so a page whose zone is empty keeps the floor. `convertHfZone` → single-paragraph doc (extra paragraphs → hard breaks; a text-less zone that carries a background/border rule line is kept, box props merged with the bottom rule from the last paragraph); `convertInline(…, hfFields=true)` keeps `text:page-number`/`-count` as field nodes. A master page whose `style:next-style-name` points at *another* master governs one page only (the "different first page" idiom): its own zones become the first-page variants and the successor's the running ones — read as the running header instead, a title-page master repeats on every page of its section and its height pushes each block onto its own page. A zone's positioned (non-`as-char`) frame keeps its wrap and its `svg:x`/`svg:y` from the page corner: it is out of flow (a letterhead or full-page title background), so `HeaderFooterLayer` paints it per page behind the body and the zone's reach ignores it — inline it would reserve a page-tall line of body space. Text boxes there are still dropped with a warning. `hfIsEmpty` counts a rule-/shading-only paragraph as non-empty so it renders and exports.
|
||||
- **Import:** `StyleResolver.masterPageHF()` reads `style:header`/`style:footer` + the header/footer-style heights; `pageGeometry()` reconstructs the body margin (page margin + zone height + spacing) and `edgeDistancesCm()` returns the raw page margins as the zone distances. `style:page-usage="mirrored"` on the page layout marks the left/right pair as **inner/outer**, so an even page swaps them (`margins.mirrored`, set only when true). The zone's gap to the body is laid out *inside* the band, not added to it, and `style:dynamic-spacing` changes neither side. The band is the declared height only as a **floor**: LibreOffice grows it to hold the zone's own lines, and a footer's first paragraph `fo:margin-top` rides the band with them — a header's `fo:margin-bottom` does not, it just hangs into the body (all probed). That growth is the runtime reach (`Editor.svelte`'s `hfReachPx`, which takes the biggest run's size as the line height and the zone paragraph's `spaceBefore`), so a page whose zone is empty keeps the floor. `convertHfZone` → single-paragraph doc (extra paragraphs → hard breaks; a text-less zone that carries a background/border rule line is kept, box props merged with the bottom rule from the last paragraph); `convertInline(…, hfFields=true)` keeps `text:page-number`/`-count` as field nodes. A master page whose `style:next-style-name` points at *another* master governs one page only (the "different first page" idiom): its own zones become the first-page variants and the successor's the running ones — read as the running header instead, a title-page master repeats on every page of its section and its height pushes each block onto its own page. A zone's positioned (non-`as-char`) frame keeps its wrap and its `svg:x`/`svg:y` from the page corner: it is out of flow (a letterhead or full-page title background), so `HeaderFooterLayer` paints it per page behind the body and the zone's reach ignores it — inline it would reserve a page-tall line of body space. Text boxes there are still dropped with a warning. `hfIsEmpty` counts a rule-/shading-only paragraph as non-empty so it renders and exports.
|
||||
|
||||
## Defaults on DOCX import
|
||||
|
||||
|
||||
+18
-1
@@ -170,6 +170,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
|
||||
|
||||
// Odd/even pages: a document-level setting (settings.xml), not a section property.
|
||||
const oddEven = docHasEvenOddHeaders(files);
|
||||
const mirrored = docHasMirrorMargins(files);
|
||||
// Page size stays document-wide, from the body-final sectPr; the margins are the
|
||||
// first section's, since that is the pair .tiptap's padding draws and every later
|
||||
// section is measured against (Editor.svelte's --pb-section-inset).
|
||||
@@ -184,7 +185,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
|
||||
return {
|
||||
content: { type: 'doc', content: blocks },
|
||||
styles: collectStyleSheet(ctx),
|
||||
margins: first.margins ?? sect.margins,
|
||||
margins: withMirror(first.margins ?? sect.margins, mirrored),
|
||||
orientation: sect.orientation,
|
||||
format: sect.format,
|
||||
tabIntervalCm: docTabInterval(files),
|
||||
@@ -1879,6 +1880,19 @@ function pushColumnRuns(inner: Node[], cols: { count: number; gapCm: number }, o
|
||||
flush();
|
||||
}
|
||||
|
||||
// Word's mirror margins are a document setting too: w:pgMar's left/right then read as
|
||||
// the inner/outer pair and an even page swaps them.
|
||||
function docHasMirrorMargins(files: Record<string, Uint8Array>): boolean {
|
||||
const bytes = files['word/settings.xml'];
|
||||
if (!bytes) return false;
|
||||
try {
|
||||
const el = parseXml(strFromU8(bytes)).getElementsByTagNameNS(W, 'mirrorMargins')[0];
|
||||
return !!el && onOff(el);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Word's "Different Odd & Even Pages" toggle lives in settings.xml, not the sectPr.
|
||||
function docHasEvenOddHeaders(files: Record<string, Uint8Array>): boolean {
|
||||
const bytes = files['word/settings.xml'];
|
||||
@@ -1949,6 +1963,9 @@ function parseSectPr(sect: Element | null, ctx: Ctx, oddEven = false): {
|
||||
}
|
||||
|
||||
// The section's own page margins (w:pgMar). Word has no first-page variant of them.
|
||||
const withMirror = (m: PageMargins | null, mirrored: boolean): PageMargins | null =>
|
||||
m && mirrored ? { ...m, mirrored: true } : m;
|
||||
|
||||
function sectMargins(sect: Element | null): PageMargins | null {
|
||||
const pgMar = sect ? fc(sect, 'pgMar') : null;
|
||||
if (!pgMar) return null;
|
||||
|
||||
@@ -630,9 +630,14 @@ export class StyleResolver {
|
||||
const margins: PageMargins = {
|
||||
top: cm('margin-top', 2.54, hf.header ? hf.headerExtraCm : 0),
|
||||
bottom: cm('margin-bottom', 2.54, hf.footer ? hf.footerExtraCm : 0),
|
||||
// Mirrored: fo:margin-left is the *inner* margin, so an even (left-hand) page
|
||||
// swaps the pair. The declared values stay the odd page's, as the file writes them.
|
||||
left: cm('margin-left', 2.12),
|
||||
right: cm('margin-right', 2.12),
|
||||
};
|
||||
if (this.pageLayoutEl(pageName)?.getAttributeNS(NS.style, 'page-usage') === 'mirrored') {
|
||||
margins.mirrored = true;
|
||||
}
|
||||
const w = lengthToCm(props.getAttributeNS(NS.fo, 'page-width'));
|
||||
const h = lengthToCm(props.getAttributeNS(NS.fo, 'page-height'));
|
||||
const orientation: Orientation = w != null && h != null && w > h ? 'landscape' : 'portrait';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Page layout settings
|
||||
|
||||
- **`pageMargins.ts`** — `PageMargins` in **cm** (default `{ top: 2, bottom: 2, left: 2, right: 2 }` — LibreOffice's; clamped 0–10). `applyMarginVars` sets `--user-margin-*` (px) on `:root`; `PX_PER_CM = 96/2.54`.
|
||||
- **`pageMargins.ts`** — `PageMargins` in **cm** (default `{ top: 2, bottom: 2, left: 2, right: 2 }` — LibreOffice's; clamped 0–10). `applyMarginVars` sets `--user-margin-*` (px) on `:root`; `PX_PER_CM = 96/2.54`. Optional `mirrored` (ODF `style:page-usage="mirrored"`, Word `w:mirrorMargins`) makes left/right the **inner/outer** pair, which an even page swaps: `--user-margin-mirror` carries the difference, `pageBreaks.ts` insets an even page's blocks by it and `HeaderFooterLayer.svelte` moves the band. The key is **absent**, never `false`, when off — margins are compared whole in the round-trip tests.
|
||||
- **`tabInterval.ts`** — the document's default tab interval in **cm** (`DEFAULT_TAB_INTERVAL_CM = 1.25`, LibreOffice's for a new document; clamped 0.05–10). `applyTabIntervalVar` sets `--tab-interval`, which is `.tiptap`'s `tab-size`. A file that declares none falls back to its format's own value, neither of them ours: `ODF_IMPLIED_TAB_CM` 2cm (measured against LibreOffice), `DOCX_IMPLIED_TAB_CM` 1.27cm — so both exports always write the value out.
|
||||
- **`spacingModel.ts`** — how the gap between two blocks is measured: `'add'` (LibreOffice's
|
||||
own — space-below **plus** space-above) or `'max'` (the larger of the two, what it uses for a
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export type PageMargins = { top: number; bottom: number; left: number; right: number }; // cm
|
||||
// cm. `mirrored` = ODF style:page-usage="mirrored" / Word's mirror margins: left and
|
||||
// right are the inner/outer pair, and an even (left-hand) page swaps them.
|
||||
export type PageMargins = {
|
||||
top: number; bottom: number; left: number; right: number; mirrored?: boolean;
|
||||
};
|
||||
|
||||
const KEY = 'odf-editor-page-margins';
|
||||
|
||||
@@ -27,6 +31,7 @@ export function loadPageMargins(): PageMargins {
|
||||
bottom: typeof parsed.bottom === 'number' ? clampCm(parsed.bottom) : DEFAULT_MARGINS.bottom,
|
||||
left: typeof parsed.left === 'number' ? clampCm(parsed.left) : DEFAULT_MARGINS.left,
|
||||
right: typeof parsed.right === 'number' ? clampCm(parsed.right) : DEFAULT_MARGINS.right,
|
||||
...(parsed.mirrored === true ? { mirrored: true } : {}),
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_MARGINS };
|
||||
@@ -46,4 +51,7 @@ export function applyMarginVars(m: PageMargins): void {
|
||||
root.setProperty('--user-margin-bottom', `${cmToPx(m.bottom)}px`);
|
||||
root.setProperty('--user-margin-left', `${cmToPx(m.left)}px`);
|
||||
root.setProperty('--user-margin-right', `${cmToPx(m.right)}px`);
|
||||
// How far an even page's text block moves right; .tiptap's padding draws the odd
|
||||
// page's pair, so pageBreaks.ts insets the even one by the difference.
|
||||
root.setProperty('--user-margin-mirror', `${m.mirrored ? cmToPx(m.right - m.left) : 0}px`);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
|
||||
import { importOdt } from '../../src/lib/import/odt';
|
||||
import { importDocx } from '../../src/lib/import/docx';
|
||||
import { buildOdt } from '../../src/lib/export/odt';
|
||||
import { buildDocx } from '../../src/lib/export/docx';
|
||||
|
||||
// ODF style:page-usage="mirrored" / Word w:mirrorMargins: the declared left and right
|
||||
// are the inner/outer pair, so an even (left-hand) page swaps them.
|
||||
const NS =
|
||||
'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" ' +
|
||||
'xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" ' +
|
||||
'xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" ' +
|
||||
'xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"';
|
||||
|
||||
function odt(usage: string): Uint8Array {
|
||||
const styles = `<?xml version="1.0"?><office:document-styles ${NS}><office:styles/>
|
||||
<office:automatic-styles>
|
||||
<style:page-layout ${usage}style:name="pm1">
|
||||
<style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm"
|
||||
fo:margin-top="2cm" fo:margin-bottom="2cm" fo:margin-left="1.5cm" fo:margin-right="3cm"/>
|
||||
</style:page-layout>
|
||||
</office:automatic-styles>
|
||||
<office:master-styles>
|
||||
<style:master-page style:name="Standard" style:page-layout-name="pm1"/>
|
||||
</office:master-styles></office:document-styles>`;
|
||||
const content = `<?xml version="1.0"?><office:document-content ${NS}>
|
||||
<office:body><office:text><text:p>Hi</text:p></office:text></office:body></office:document-content>`;
|
||||
return zipSync({ 'content.xml': strToU8(content), 'styles.xml': strToU8(styles) });
|
||||
}
|
||||
|
||||
const doc = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hi' }] }] };
|
||||
|
||||
describe('mirrored page margins', () => {
|
||||
it('reads style:page-usage from the page layout', () => {
|
||||
expect(importOdt(odt('style:page-usage="mirrored" ')).margins).toMatchObject({
|
||||
left: 1.5, right: 3, mirrored: true,
|
||||
});
|
||||
// Absent, not false: an unmirrored document's margins keep the plain four.
|
||||
expect(importOdt(odt('')).margins).toMatchObject({ left: 1.5, right: 3 });
|
||||
expect(importOdt(odt('')).margins?.mirrored).toBeUndefined();
|
||||
});
|
||||
|
||||
it('round-trips through an ODF export', async () => {
|
||||
const margins = { top: 2, bottom: 2, left: 1.5, right: 3, mirrored: true };
|
||||
const bytes = await buildOdt(doc, margins);
|
||||
const styles = strFromU8(unzipSync(bytes)['styles.xml']);
|
||||
expect(styles).toContain('style:page-usage="mirrored"');
|
||||
expect(importOdt(bytes).margins).toMatchObject({ left: 1.5, right: 3, mirrored: true });
|
||||
});
|
||||
|
||||
it('leaves an unmirrored export alone', async () => {
|
||||
const bytes = await buildOdt(doc, { top: 2, bottom: 2, left: 1.5, right: 3 });
|
||||
expect(strFromU8(unzipSync(bytes)['styles.xml'])).not.toContain('page-usage="mirrored"');
|
||||
expect(importOdt(bytes).margins?.mirrored).toBeUndefined();
|
||||
});
|
||||
|
||||
it('round-trips through a DOCX export as w:mirrorMargins', async () => {
|
||||
const margins = { top: 2, bottom: 2, left: 1.5, right: 3, mirrored: true };
|
||||
const bytes = await buildDocx(doc, margins);
|
||||
expect(strFromU8(unzipSync(bytes)['word/settings.xml'])).toContain('mirrorMargins');
|
||||
expect(importDocx(bytes).margins).toMatchObject({ left: 1.5, right: 3, mirrored: true });
|
||||
});
|
||||
|
||||
it('leaves an unmirrored DOCX export alone', async () => {
|
||||
const bytes = await buildDocx(doc, { top: 2, bottom: 2, left: 1.5, right: 3 });
|
||||
expect(strFromU8(unzipSync(bytes)['word/settings.xml'])).not.toContain('mirrorMargins');
|
||||
expect(importDocx(bytes).margins?.mirrored).toBeFalsy();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user