From 76c9564028eb8b65eba8c9c414ef7da40c735d7c Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 5 Sep 2026 15:41:26 +0200 Subject: [PATCH] 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. --- docs/architecture/pagination.md | 2 +- docs/showcase/book.docx | Bin 126468 -> 126467 bytes docs/showcase/book.odt | Bin 60957 -> 60958 bytes docs/showcase/newsletter.docx | Bin 96695 -> 96695 bytes docs/showcase/newsletter.odt | Bin 96062 -> 96060 bytes docs/showcase/thesis-review.docx | Bin 60096 -> 60095 bytes docs/showcase/thesis-review.odt | Bin 21406 -> 21408 bytes docs/showcase/thesis.docx | Bin 57334 -> 57333 bytes docs/showcase/thesis.odt | Bin 20350 -> 20349 bytes src/lib/editor/extensions.ts | 8 +++- src/lib/editor/extensions/CLAUDE.md | 8 +++- src/lib/editor/extensions/columns.ts | 10 +++- src/lib/export/CLAUDE.md | 3 ++ src/lib/export/docx.ts | 31 +++++++++++- src/lib/export/odt.ts | 69 +++++++++++++++++++++++++-- src/lib/import/CLAUDE.md | 1 + src/lib/import/docx.ts | 10 +++- tests/unit/list-item-blocks.test.ts | 35 ++++++++++++++ tests/unit/note-image.test.ts | 34 +++++++++++++ 19 files changed, 198 insertions(+), 13 deletions(-) create mode 100644 tests/unit/list-item-blocks.test.ts create mode 100644 tests/unit/note-image.test.ts 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 19a20bc6720f0f1fc61a5601396cf125c519c631..d8324596dd778f5cbed0bb5963de5e1f65840ba4 100644 GIT binary patch delta 5933 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 delta 5960 zcmZvf30&0G7RTp~2(l@|q6ExWBOJ*A1%HQ?1%3~GykW>%hwTI9=6cyq_(iGc+J}MYhUY(>3-3L zH(>pnS>OF?NPT``S&+lnFCCZf8g4yc%|?sJ{!_iC^mm!EG$(Y`l*$hhUYvFDtzX{> zouGg5h4}g<@im`>r9N3Uc+@uUOKC%AR(beEZqj}@d0@b>kauRBEh9FY{4#ZUzSnPe zv)r7aI~crg@W}WntBXBey0SRGYF_s4gNLWAFUpI0Ha*>=cXaZ>#S>BwynENapxb+W z-H*k4{E=*(6Exd;p+)(=SI4|LVcdwS`Fqy&n&q6f$}xY)@N~x&u>)>jXiT`D?K!8a zwJ62?r_q;Z|I^d1?bx>kyT82^c`NMrml+Sgju~?Os|oLG`;Xf+Hz$gm7?WfZ(8gBlM-rrKBZk3N%kv(HD42eTb^#`}l=xyJQK(zMqW z&FWFvbU!BdPHo|>nvXLooPMec|E>O0OO0(~aeco}iod!W?g@h?=O;JxXpVJl3T%$; z*%TM=W`E&$RCC+JX7AT_hPO{T?9%VMUs`reThT>+P=3*des^A5q19;S*nK_q_nq`f zW&QrAFMUuR5A6w+wU2zVUVgNi-{u>g?T>}ylQp{CbbHfu3*Ao=o2-fLON|49pBO2~ z?GRe|l!mei`J7*T;k`;?1E+O_jz-@X77-#CUvSf^_fe-19Ob5h^SY?P9v13-v2-8Z zRrPDCFhO@qmH?7%aH{>5EXu{dtR%6&+O#Ad&Fv;d!%owhwNj@4TARXjWqgeOOot@R zwkFCK-nU}{*X#p9_GArTzYkoL?pJnKeZL;+{Tb;#;ViL*l@7*z@c}m-1Mz9)PWq(!vSaJ!!r{-}=>4(lt4)0dDHCxma;=4V-0(e)$m(=P&Lhd(JN zcmwzULG()M05Xl*fN{O@TOe7$6heAsbujt2N{$;Wliv-M$?+kaR50gamLO;hF(_+8 z1r#|M(>aVLdsw=wGg1BcJekhwK{A64VNk<38Oj~cAz*D>8X4l9LMN ztT4`LP=-tuOTct`W&d=t&^WO{35gO*!0MJ5m!Mac#K=&W*8@pp!jPP;4sps+A-(c8kOE^&ue|s%8LO&2+l9h8^@{DM zGI?o-Ope?olexJvx!^OItlce>Lq3;D?H-w&yH_TwzmQ4TFO!cC$Ye}`Og>l0W6Gi; za*v(K*hAzv{j~ZXCZ%}YbK@{!e)4hR36>>6-`|w-^}pzF+9mZgc@|HYH%<2T zaSvN2m5X@Fre68f29BKJQPtQZ`$J ziQSbAxSx{JW@lyMqTOJ@$Awg>^BpI$$tEFmPt@%Lg*G!DI zRG8SZb(m+a4ae(b+}@U(*vzRp4hi#3*^IK|BsO;DPm5?Fs0O#}u|$(EsAt+$ZA5n% zSc9wIQX@_ChcN49b7v1u*7U$>2Aq|)b*uxAEI2FWxF<|^owQtb)U$M;J5QW)&T*P~ zA&$(039{KFOzaZPKLOfxP8`Q}k!=pmLHxntyn6geEtSrIt)jZ!Y_6p)d~or3b+A_7 zlbhH)nlft3g*m>Ko9tA?_u^!5CFx~4oDQJ7fIA)Lijx^X*X-0PG@x$x>(B7P*jkzf zYrDE}6WdGGJRnT$FtJgrleYU+caCGbsp5}>39^~z!Aa~qZOuLjWIV)vkDG&Cs3`?$ zqn=u7j)4y@l^AWPCpX{n#Eix-E4D4yMdZ~V)Rfu`FOKh!&EVeLoYlKyj(QQfa9YZ- z%A4Z_-W_n#7m9vV3npi&bOCH>)ep=PUp^SSTGK+c9)8@!#ui)Mi_+GXiwJhPDn7pt z$A?{#OkH1YV#8}|{%I%?5$t|VIYFK%BfHD)(g*047ISnd> zWN;01ZBSwF8No!eG%;mP;JmolIdLgN0+uaE=<2>|CHN~?A|!*C!66|j?9(>UtongN zJ1L*eX}=;|0zr3^IMZ@i$`>H{Cq5yY{~IV2k`+4++Jt1V9R&O=!D+BSNCrE=0U;Uu zBeckHIP`ClR@MOVLNYiP_6o_$=mocAI3N04;bR#*AL4{$@Kwkcl9u7KFi~1;T*{!} zPIiT+u^*f)fk5u^U12HrL+n+l*x`^XB%A*UGzm$r;UioEhHLzJhf#S6W(vuo`LJD< zR&Ys%=R?nCK9<7XL)VCs<|ZaCj!Wt6fimvnG&62kCrf0tw8MERJvjy4uk+dRZegYV z@X~d@O3&LUw?KxFEIJdaWqAy&Z%D8&j2Du@4`72Vr@%=W{sy|<z%0=We#y zVg`nEvKm^T&&yy21_yNlvI*RQ+>+)?h0#K?bv8q)94&id2~Kv?Xmo0A rc$WYct!j!c38e%!mg0X^#^2#2I;|6IZk4K-3pK4I$g+DGX4L!*Ip@`o diff --git a/docs/showcase/book.odt b/docs/showcase/book.odt index 6c2e4c684a53b0ef1e4298447ebb9f0a2c32c8e0..ba6c34b61d8f973a459e1d40ef29aa739d3feb0b 100644 GIT binary patch delta 492 zcmbPxhk4!|W}X0VW)=|!5b&#>$m7lASG_Uv-)$hZIr2^@PrYBYYOH&DO_B~H14Aqm z0|N(8VQy+kqFzOAPVXegtiuKZuHP+NFWj5(7}sc$z{7sCqwB( z(bX!^>No%D#eYgV+Fcs8X47{^+jjA#lWbljEVwso$HDX0x2s2&c1dsNv$^PTPsdfO zz-@t6@_fd3&8Dvww0i2zE1WO>*NEke}bspZ%jbc$2aC6O6#o|Cp@SpXTH@13C?&c zWBu(V14D6YNl9j2dNC->1H2iTL>NF31~ODNcKZB8>tjG(J}_=Xfiwbao&5f-2HUzf GAQ1qOQP2ti delta 491 zcmbPthk5QDW}X0VW)=|!5a^DW$m7k_9kDU;-)$hZIr2^@PkncUYV4}E-Z|Qg3=FYM z3=AAVg}JFEiFy^eIlYq{{SF%lxPG^7eOmj-BtRi-(kiD7Ns%o_&TU$LbdvG9oaH4a zO?SwtAIub*7_#cfrFq}~&(Gf(GhfQN@4=pJ5f@Wu1u|)@y|iQfi`U;QD;K=33cPjK z&-=ltQ`;DMJeeIQXt+++eJNPaAfsC2^|?5p{&M8s@Vgs>*G>}s%kl7lr1h3_ORl}0 zIf;j-MCtsrmy-gGQv9OsUD)q8H{E9L+V2eyTsEw$3u=hAXq^b6$NUn#B#)H>PB$M&kXQ~FO$ZS|(W<2wpp9}w<+a@J)L zQ##8kqi>QA>>?KPMa_8fM)G@QqyO8Jiwn+489#|NihUqjXY_K#>)bA-ZIYdn&dTT8 zocrhgmm3ze9KfL6%=Bt6BQR7aAAJ22MD2NF0-`>>F$YoFZ_PO2Aw42LQ6mbZ5n#*Y4{tTt*1iFW F008iZ(y{;m diff --git a/docs/showcase/newsletter.docx b/docs/showcase/newsletter.docx index 80f42ad6aef060adb914c3cdd5ca0af836f12f64..1b92c948ca27a6eb1a13d6882e5d179645c8cd3c 100644 GIT binary patch delta 805 zcmdn~nRWYTR-OQFW)=|!1_lm>;OdDy%1l7gP!mXQjG4d)q&B}}yeJH!4~jl#0n(FK zNvU%LSF6Sf8+87fd{auR{#NFk4CCgV@$1%^tMy&n;cRhk0jrcn)bIDKbFQ7A>;1{j zW{*sSto_VBnK}un{?ku>0}V$HyxNquUZ=_Y;b~sWN@uCwWcmCrtX8eYe?`9V@Es1| z^L0!9KPz_bKkM`1x?6lFPjdL_byCc2oDd?wl-DaOOjcN=%i&3!D3ICNs*&u|ICUpB1qG*km;sd5in?X6jRxM~Ykx z43y1zwJcWW_Olf>HW4w)Pwy$-<8^l5k?m_Hw>*FL-rlgD4LR^P*UB7b0)?=Xyb375 zGvs+e5iq%2VF`#Ut0)hW-E60P4J<6CdJrUgQ*}R(T0Bb)taXmYGLRY<&Cg&pG6u5K zt;!h%C!f|R2Z=w>wgQ_WqxTOgzZ4{V*dP$houeT?y|02%V6vA1NcluV@yS<>gn+E( zmqy!P8Zq8r1G2Xtwq$%_0c1>HS;S}vqVE(jnuBQ3Vnz=T9be373!>*0Gg^b_+r^Bg z@FX)`q6DHku!PYLBs;N$(FjBzg^K?xVRQ%a-Afs5Ky+&E IRmqqO00ag!9RL6T delta 805 zcmdn~nRWYTR-OQFW)=|!1_lm>2@w-{l$n5}p(c>r7&CzpNNs+{cu^Qc9~6Dg0;DIe zl2YfG5TP1ta_zJIDlI;* zWAB$@>Xeg?x~cz?ZDYP9cM-E&i;<97+xBzPjfF3-uMz(gx2ExJ$W=Y#2B&&X5gpF% zI_xapMEz{dV_c!?o&6~ad|B*kE$brAPR^~7hD1@ElRX_or zAor6A$M27zGi91Z#DeHDxXlf4W;$|o9%Prhm-1Y|Y8 zG}`{si17v+kiGq|CF2tdAY=NrC+yqY$e@z9%Wlt*@lR!j}VaB94@?+y*{{FH8!}e;(;S01H(}! z1_lnG!rau7M7@gKoZd->`OO9bt>6D}owk-}l2R2DD9t#ox76GG`aYGsgMDund;Y66 zWE5TG_A2B4_j`M-t?$p9J*%eq^Q~D;%L<)1B{HX){chS9dj8_9ywk3E<<`25+S<3- zc-(jwD9pW5QKu`qrM|Q+`r+2+ZYB>bz4H1K+&Jf7+LK?hmow~tD0|te&`k^2k0__V zxv|S?Ti*F8DFMy`mD7%CE>3x2In!wIeq+}2@2*6@KiD9g5&Lt=fsle@bMA_;1z8E) z*y-w}Fy(a0&aO$mQ*1&qk4@K}m;0!6)?WTsmU~W2eX?R>+b34PeQYiT^(x5+Ro*}1 z-|F!5+hbvsZOHyY#ar?6q2SJAznV^;vS>ktqr1G258Br`9)7!m%LWB_4dm>FQ&WctN2MkTPKFL$*)w|O%#fH10}J=UcOymK8d2N@PwoyWO-e^!&w1>rT(UlV=vi6dtyX zSE5JC!O?2v$3HPz8TGGuwjatq-o32YZCQ6s$O-)g=$A*)jy%R8#i z-Pl)JnsaxW=E((Z3ZHa(gWXPEC^kxUt54^ecX#FXdrge$7j}K}Y+U)E!*aI<&yo^_ z4aMD495lpFKC<%)3oHs()t-0flTNpM*v;Py<{dWQIZ4L!TEM;o2d_79)c2+6%y<&} zXQ7zJ{koc_BX+!%UtX-)c(!v-)JjHwTN|b45%C395zD;{H#s+btn>SM@j1VnbZ^v^ z!1;S;@4m%2aa(4|bTPK0s=`X~57nnS#NNGS#2YQ$Ip?f=rp>v3i9eZP;m-jKf7RG2 zygFB=0)x7Sje&s&7}T4~WmYqSB11(^3Pd+M$ZvO$XVm2avbX1JGCl`I*z|}}MiUU- zSITG(qK}s{nsLHoZaP~Tqpl4$(?PKg0nsuH48^G>C7F5Y#h|DS@MdHZVF0BIkh`Ym zmodsPN=@%6V^om`8N?+wC+Qz2BLfHv!^{BFX4B7?F)D!-eYvacxy_q_0fbQ%rA`+p NXH;YJDFynJ0RW(l@ErgE diff --git a/docs/showcase/thesis-review.docx b/docs/showcase/thesis-review.docx index 2c3379861a6397e34311c3f6501a274b1d272218..306c4fea8641092096c8c627934f8ae02a2f92bd 100644 GIT binary patch delta 1302 zcmX?bm3jYFW}X0VW)=|!1_llW@9K#>%1l7gP!mXQj9JeJq&D+1xvPQbFb#WFAU%1y zt~#f8wQ8(?BumbQ$;Wl&>+`P|2(GyuV(0g~h@ouAg?pdqVT}H_XOG0nST)WwSH}s!fvP4T@ z(gwC`H#rSb6Rv$|G#27QYEIREzf`@ySsniU2KZQ2*SiPwtf!uze8gctwZ zxc;7OYvS~4EcxuqcyCu&EB>fa77)1|y}n+8H-t&acfYcDe~-uE+xP1o+Um3?NL8xO zIof}s%-x~a(~5ghM3un7U;nzlHobM^53%oj=5Xge?;JI)&b`9yxh@NsEs}$`bf=$D z+x%sM7_;=+riG8sPgY7)(2us7n$4S4{A>BG{~u>OdgymN`1R@~5&NE4y?uUA(YIOc zp52>to4z-*Py6p)y=MLCs;SfEKYgkRH_hFf^?I2_{;c?o2gQ5THGcZ5-E*0%SX=b( z)34wAkz;)G0{v8GP_(ietAJuz*O(U+>yvFvmOv=w$*iW0Aej!+|12Q7)5--*pSR;+ z0x7xaa1cbzaoi7*jdeN;7S(ZG2I5|E{R}p4mapvOzUP9Iqg~5E!UY~KAXS^Uc|8KF zp5?m~#C7xw1gq9@m7l!Xza1zv(NKJHaDWg_2y#wUn&9m-vgB34-@Bzr3 zJoDjg5LNcb5J*jWDKq)#BWaMpqemVfO6RdHh{}F!4Wd>&HbqLq&mNnB1m&K{FiqB< zyg^@Na=;TFkVxDU8Pi`z3=Hu_sX4{^0p5&EA`Gysq8cl|c+#|Iz>IT_iGhI|NF#v4 zRaKn9$CBEzUZ+3=~z7QKj46Jb`kP|R@+cQOw%*|&qO!BUi zZGeF}|2dC5D9_q^_4OWOWMG)Y#=szma4(Q`e6r(n1&}W77cz`{CO16S265Itmtjnv zd&~+ESl40zb-1ka{E&UNlIRIUr^CkcQ delta 1239 zcmdmgmHEI`W}X0VW)=|!1_lm>j);jo%1l7gP!mXQj9JeJq&D+1xvPQbFb#WFAU%1y zt~zH&glcR;1LMh!laK4l*B4(l5NP|~b&TC_YwB`=sK+aeH7^S@rf~?j&7QEWbA@i^ zmaMyfs>8D0Zo43Dp84%=`u)Ez^xn14yX)xi_|2+}Rl5=rrd~ewF8t{Cx1p6`20fRu zZd!A%e#8+G)8Y3sY$ro}^JjrYj44Mp-q6~w#b@|yT5$-wgi6?w4I+EW5+_Wo-b}W)eF&=B6F^O&q#Y{(Ra0C*3u{YAKIJE-}f}kJNfbw5s^igSM{oWQz&!WJ@Mk7 z8`B@~U)toIo?KvSJZ){Ww4l?@X&kfO#$2fHVOzy8aoPQe-R^FS9==_Ff5D;~9!uQr zdA$F?yy-3Dg@kE-O)6oGDJ70IWsaLJlVCDR$eRi8F34BlzB;QI0XtNu#+Ycjs`u`<1wa`cbt^-6nwzDy~T zkKYpQgKbmFt9}+%hjzH`jhObeGIIazYtM84Zp)o^_0!**=dV2AJ|;ZnPrC4b*SQKx z*7cQtAMqnc`R4ihsm!2wWiwU*MYE1EFDTk4KQvLE9B0x9VJ-p5w445C0nr^+E@1kc z9S0Lg`i8?n5H;IzKS(yl=`2`O+jSX;d)f6f*tnU#vXgtC3r>!5Ee8qbd$@p9ZQknj z2&{Uh?@|!g!7mW3TH95A@+SXwpwL7^@yS5}LLkYsfbwn-egF0jkb^hRyw43*yzIdT zAb0YNhqpmg=_5lB_5X#;4#W zh8Pzgno^)F~n`TdTe5DK0 TQ1D8Iv3GLsD;>6sM<5jd{7U+y diff --git a/docs/showcase/thesis-review.odt b/docs/showcase/thesis-review.odt index d14b74bbf87b279ba63df460ad21b0eabc621964..d88968e4ad061729767af75ef217fb7c5b391be7 100644 GIT binary patch delta 1205 zcmbQYoN>W&MxFp~W)=|!5b&;^$m7lAUA-}KtpuVr^cZVQRGe)6y7{jJEQ zs;8G^IhRWQJ|wYvcEh>nJWaWuKJ&OIdp0tCnmi-qlGn7~Z|B_UnIB-hiE-xclO;{b zOJ4pmvC}ZQF#q?=)`_?M{Z$VBu>NxVV!wYq11y3#7`z#)H-9qO%Ls~`jb^LCbex3; z6Occ7h1Gu$wbJ?uh^n_a0aD~?%L`I8Il>OaovdX43PkZaTm?x!b+`dGVZW0gBL^s& zH!i<=bh4p`$mBQ9>p|+~x?BTMrLI3fRKD9I5EbkW7S#4Q3F2vcNKF>-1gYM9(6bk8 zSAaLzE-9b)AO+mMCLqeu*BnGu`I;dmsGYvLAi)>DGBz(v7#NCEOG+~H(u?&fa&rQ_ z8JR>FK*zYL@O>r>k4|3CK&xtcv0NrEEg6^i_p*KpCdVE|b@~h)*sE1Ut4aP=+yk@@$CoK_Dk}^1VP6 zuv?a<%cd^{+VcvCQ3Ks`vSJX}ysjV_UPTXhpctCMLIo(n_-68Qh^n(eGECb&Ctvgw v2RRMo4uN19CO7ZN3Ep54uxJP|N-_Wd%NVHn delta 1170 zcmZ3moN?ZAMxFp~W)=|!5a@`Q$m7k_5wS6Htp=~1)b@GY{h5VZ zET2jKWXe@pwbfj*U5@c+pvTm8d#^t^xc{r!M~y>&P(8G5o>%tz zD_u&)mjaF!8C)sqVqM|q^xGs?&g$-!u>YagbL!)@f84T;h`ykp;Wf86aCzec#*~0= z9|K8$rv5GY-*(NZ+;4kz-n%Oi%NIBpcU$cfUm#?0OT$8Yi^}x_Yl=U!ZWi!A{PGfi zc-;<&#x~RC$ z)K#2U)8247uUW-xHD8;(t`mAmL7v)?&&-R9jO(1vp5RM)rgoX@+{9h4 zPgkrDaISx!zOmr<%MJ~Ry7qS#KR$lsheZzuFnUxse>K^|2#T7`W~;$;f`tbYkUx2q z)qfDR+WHEJYP2~4QsiyR3sN*W$_~VxtYZHPLkklB==KOig}Q?Ubv?jJbUmad3wf>wN^Cyt*$dVjx34Zp@RhHO^)nL&hT_zclFYpHV!evooB(e|CJ_cuQUv=oHZf8Ee-$GG z!&V^%22pSVg%L|9JNl_8fKq1KwcSRNObiUWSs56FVbWka$YOGVh3Mp2emo%iR`|&< z&Y67FPZ?}EkTZ8OyFZwt<1fQhY%_VHjo9P_f3Vs-e;G#G$=wj?4M2|1iA}x(asICW8OCFi#RFA9(zbyzOcPxu zuXhoLNjC(_FlJ7k8>j+yWIfZWxQ#$}@c=DE4Y%aU4+2%RK@M7)E}Om-sOA+AqpERt zhdap71m>ciAQ>ikkIA+k;*;xwz<~r5VSF`tWf0g!=YgE9liviXfCJ#)kKG#6Co=|$ iGP+FG4~Dqu-&uxFdw_NcV7Q5Ia#64b+g~4`Bm)2ruA8j@ diff --git a/docs/showcase/thesis.docx b/docs/showcase/thesis.docx index c55484909cd62d0f4b099a13ec06453c50644c41..23c6efb89c7da32a8225d9c0f634af079255f30b 100644 GIT binary patch delta 1355 zcmeyipZV*4W}X0VW)=|!1_llW*XoHp%1l7gP!mXQjA>^CQk$PLu2lumQ`GHQf%N37 zI_g5M)vB?teY$EkFfuTBFf%X+GE8Q?C0xIB(&@b01_G}2trr%pJ~1Wd%8JmLjB}J$ z@6rtr*V~nRvh%E)5+|SV(fa#}!kXt}%T%77n`zzu?a#XRH*{G{&aLaN3KQj7Q*`Ub z;axwr?$)sv^$Ql=@LJ+#=kfl7@us_i7ZRrRHK~L#rj$6=6gzIRozeFy{i1}&@7-;e9aIA= zDhJlUgnFaeM z>SQcY`o=uu&mWtknH65Hp@WV>9(K2ihAsqg1E*GE?~WtPXD07vXiH|bbvLw z27-m&+sIFjzb7zxmwh=o!+j;|6PJyZ#Btom_Rp z5JauLVGg36-S7ZW1~+X%RQ^qC5Vhu}DJMM1PJVUMOdiBzE%CW-4otjFz|=1U%m4^* zakBj_MX(x>7!y>24WR(YvY1?dOVk=C~;ecQFyZHZF#V@JIbOa9tGwt8%_oWVH6{0PAD}{zZk!1;6xF}NR+9~^ zL?(OO;Q=eWRQ_jsF3{9!AeIGU1bAdK`9Cn4WbX2Sj9++1hOuk%zB>va&I2H)e)8Wt z+8~bBT^UBT$?kW-GMPY5&g3=-2dIoG$7OP-i`-8B)^TjrVR9~GcYdD+%=j1 z9wZ)cJ1K3l|2-X$oojE%F!D{Fbx#WHheDUjhAQ0*44dXMFqk7e0AyLZPY!dpoXmS4 z><8)lGEAE;O+J4~bg~~rB<4QIxs~_T!H#pW-{ZXvs7np#Ksgk1Dkty1Z^)K?10)Cl D6cpg| delta 1400 zcmeympZVK#)yeL%1l7gP!mXQjA>^CQk$PLu2lumQ`GHQf%N37 zI_mX}5vs9r4E_5yGBPlDFf%X+0`;flCkGVe7ZmF!=NF~wRpjO@o#g9($3UR%f7euJ ze-*zn%_fuVB}*S&$<;1hy|$|OZQ^y+jk^7cZ}z{xF@ZsM`@NjA*5~(pz8GuAW4pV( z;n>a4iy@^44`?m#y}Q2a{q5JE_9RFwy}GGfEbM5#;D#L{b3d&uV%*2}T+xO3L`U+* z(7I5Wq%XS9SMV8ltnorn7!)Z`CA-`2h4?wp0pMje0;HTvH6nVf-w69Gg}{9ggrL$K3RXb zKF6;1^xA31mU-4|YPc*9mG*t(_@<{!dGXJU`UmBgq=uh6_Mj?FCz{<{sijC)(B$^c z1(LiWj7q-ymBsygTn_J!-|y(UaiT}>p2_zfvZvf;UT{!nE{o?HrV}rke!OW)u{N;1 zdVZ1Sgm2~C$0vF%`cRa=rl*8KJ;$f)W6RE*X>z9$Q<^VdDF2fhl&bXQNviYmqGX5l z{q>>0&F2L%-I-mIwdiEmPw#b~>*QoFnPfbA`?zk|iM-0Mh1Ibgu6r%CUw3b++rL)- z)~_vVr(gZ~=}UUn2hqopUjH_C{$J!LFmdkweRUt%krTt_yLze2pcr>CQUN7^EF)e} zf|y)syadFRH)&x4(M!#~fQ4sU90bY6S?&kRTAc;SPJU;z48)yg`x&gL+kPpCYvkYp z)?4B94=OA>d8$hXSfgtoSm>RN{N%WM0+V;zmji_-8j6eLrlw>j>N7K-xY5tHd|L^e zryu>9duuXK9^@ZXR|H!k@U50KF|*{fvyk&nt%WzR!~2r<>!~=<(H%u zW9Vm$m~41YhUu}*F-O~Y?vgU@2HNQL5ZIJYZ z9<+rnmkm|A85lOrWneHz_z1|da-SUPZaJCfKG>;J_hlG2Pd2}=1Tr@o$jO~tabF$8 SSqS7*P2P9kkS*s1$N&K3Ndhvys~V8n9H9P`z23E2HMYWcVR#861A{&@ z0|N(8VQy+kqFzOAPVc0%dCdj_E$=J2y1o5bJZ@=h<(L?>b^AY=6-r;9-I9JJw&#SI zYVB_+CM}oDD~tEu?@2G0UC;08x6eVkGwZ4ArCQ=~`P;(BzR6$XOAqsHZCbx) z?^adbrcIZAij<0R$T{%!o?y9X!ew)5ujPxPcYnWKsn`{qJ-3nRtj?<^aSdJ%6COWU za$#Z&^X7AB#m_hyai??J@A-Oow#lr^H=EoZmh9Y7_xeh5g%G#j+AWqU_0gIe)Ov9F?X-WmyDZG}q|wS-0kOyS86111w&3&5hJ@LFj1FIn2wKqaM@MbymPt?R ztkumQt6%BKaeS6%H1~l><}SN_elA}5FW>8?d2gRTucP^6`HTL=^7H>Nz+#L87-O3+ z8SG^QMb<>4)nMAr#DfXQpWI>gA4GMUUjb3^7AHW8G%a~SiY8lFfw+^ItY3ksch*-y zl1FWBfK8ZhC&|>8AS%@H2Z-`_dIX}3oxy_KE+;`e zZWpP^A6!7HH!pPU1>0rd4z}x;`+Jar*B&MyO3~9CL`8X;al#YW!Z4bI$`stbqnYas*6awHX70TYgb)X-=Zy zWJX^Jurs}6cxuey;zlrWE^is8AdAWQ7Gjg_ym>$>eY|BDWhQ5OD}y+FK#un0)ez1F zAm{7kx8BO&Pzc+_)VFl4reN;fMk@l5gDsY(G>mWWkz!&V;IA0k? zkIB`(VCjWGj??6QzA9k1EKQe9UkbG66%eBay4qw$Kd^=@KN;SC&hS7nG=+r8Q(Px+bQK3V4djjwKoMp4$qw#d5wLCl&N6)31GGs1=qps)-cAni*I>Kj I0hD9_05l4wxc~qF delta 1232 zcmex6kMZ9;MxFp~W)=|!5NM2;$m7k_7_l+(s~V8n9H9P`y}mI*H8%Fj`5mQ<3=I0r z3=AAVg}JFEiFy^eIlYrk`yDnAaQ$A>I+b^|$xG&2QJGqz2m6jItq`(K_78S`)S1&h zW&d$c;fWzjlQPfmyk4FrvzEWoZJ&d5XV%kR_l52bg(5*qwcndoeh|NBwXdsS`(&9F z4sp*GteM}Odc-S7$(mpgr z;ry-}#;)%IB6}q#cIB9av2EF($i{MWk=PSgW%lGdc}myQUI*@;`ROn7j$Zkh8yBqS z_5AiA?Pfsi+Zn%w_i3k#it9{W#d$UD4VUwpRm@iNxjD>E>Kn`Ss#6L42v=jV3cjX zXt0+N6k8LFR)c9<6AvaJe{#Foe-PDSeg#CuS)2eV(y-(ODVl6<1>#O-w0;Gm-dbM; zNglDe0XAWtogi4zM*EW>*&2syAS%T12Z-`>dIX}3oWX)zE?^~GE>e@Kd@Rs9~s6+ll6R*o`DcrdH>e0#heqv0^>?a?z7l*k{%2$T5aI(3t3dnV_K#u3+ zDhOu*kmEdgudfQ&Elbm7)0YD6c?HCxK#TzDll;J%GyP+9}RLkB?_V?FdyXyh69sm$f Bpp*ar 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'); + }); +});