feat: a text box keeps its lists and pictures on the way into Word
A list inside a box was flattened to literal bullet/number runs and a picture was dropped: the box XML is a hand-built string, and neither had the package parts it needs. applyTextBoxesDocx already holds the whole unzipped package, so it mints them — a media entry with its relationship, and an abstractNum/num pair above the package's own ids, the paragraphs carrying a sentinel until those ids exist. Verified through LibreOffice and back through our own importer, nesting, marker symbols and start values intact.
This commit is contained in:
1 parent
4cda289272
commit
d344494fcd
4 files changed
+205
-33
No files matched your search
+1
-1
@@ -80,6 +80,7 @@
|
||||
- Bookmarks and cross-references: name a selection, then insert a reference to it (its text, its number or its page); the reference follows the text it points at. Round-trips to ODF `text:bookmark-start`/`text:bookmark-ref` and Word's `Bookmark` + `REF`/`PAGEREF` fields
|
||||
- Manual page break (Ctrl+Enter); round-trips to ODF `fo:break-before`
|
||||
- Text boxes and basic shapes — rectangle, rounded rectangle, ellipse, triangles, diamond, pentagon, hexagon, five-point star, trapezoid, parallelogram and the four block arrows: editable block content, fill and border colors, border width, resize/rotate handles, text wrap (inline / left / right / top-bottom) like images; a shape gallery in the floating toolbar and in the ribbon's Shape tab. Each outline is one polygon in `utils/shapes.ts` that the editor draws as SVG and the ODF export scales into its `draw:enhanced-path`, so the shape on screen is the shape in the file; Word gets the preset's name and draws its own. Round-trips to ODF `draw:frame`/`draw:text-box` + `draw:custom-shape` and DOCX DrawingML `wps:wsp`/`wps:txbx` (imports Word's `mc:AlternateContent` and legacy VML text boxes too)
|
||||
- A text box carries its content into Word intact: a list inside one is a real Word list with its own numbering definition (nesting, marker symbols and start values included) and a picture inside one is a real picture, its bytes and relationship minted beside the package's own. Both were flattened to literal `•`/`1.` markers and dropped, respectively
|
||||
|
||||
**Page & layout**
|
||||
- Page margins (cm) and page orientation (portrait / landscape)
|
||||
@@ -132,7 +133,6 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08.
|
||||
**Content an imported document loses**
|
||||
- Charts are **drawn** from the file (`import/chart.ts`: DrawingML `chartN.xml` and ODF `chart:chart`), but as a picture, not a chart object — a re-export carries the drawing and the numbers behind it are no longer editable. The same holds for an **EMF** metafile (`import/emf.ts`): it is drawn, but as the SVG picture it was rebuilt into, and only from the record set a plot consists of — a hatched brush, a clipping region or a rotated bitmap is skipped. **WMF/SVM** metafiles and OLE objects still keep their box and a placeholder label, and export writes that back out: WMF is a different (16-bit) record format, SVM is StarOffice-proprietary, and an OLE object cannot be rendered without its application
|
||||
- Shapes with no text area of their own: lines, arrows as a *line* rather than a block arrow, connectors and freeform curves — they are two endpoints, not a box, and ODF gives each its own element (`draw:line`, `draw:polyline`) rather than a `draw:custom-shape` preset. Rotated shape text (`w:bodyPr vert`) too. All dropped on import with a warning
|
||||
- Text boxes in the DOCX export: lists inside a box are flattened to literal-marker paragraphs (`•` / `1.`) and images inside a box are dropped — the box XML is injected by a post-pack string pass (`export/docx.ts` `applyTextBoxesDocx`) that mints no numbering.xml or media/rels entries. The ODT export has neither limitation
|
||||
|
||||
**Missing while writing**
|
||||
- One colour for every tracked change, where both word processors give each author their own — and no reviewing pane listing the revisions the way the comments pane lists comments
|
||||
|
||||
@@ -22,7 +22,8 @@ Frames, tables and pagination have their own deep-dives in `docs/architecture/`.
|
||||
- **Hard breaks & tabs** (`replaceHardBreaks`/`replaceTabs`/`applyInlineSentinels`): `hardBreak` nodes become `LBR`-sentinel text and tab chars become `TAB`-sentinel text so both survive every odf-kit path (a literal `\t` would collapse to a space), then the sentinels are rewritten to `<text:line-break/>`/`<text:tab/>` in content.xml.
|
||||
- **Images** (`replaceImages`/`applyImages`): odf-kit's native image path rounds dimensions to whole cm and doesn't reach our cell path, so images bypass it. `replaceImages` (a pre-pass) swaps each `image` node for an `IMG`-sentinel text run and collects the bytes (decoded data-URI) + cm geometry (px→cm via `round3`); the sentinel rides every odf-kit path including table cells. `applyImages` (after `applyCellBlocks`) rewrites each sentinel to a `<draw:frame text:anchor-type="as-char" svg:width/height><draw:image xlink:href="Pictures/imageN.png"/></draw:frame>`, adds the binary `Pictures/` entries to the zip, and appends a `<manifest:file-entry>` per picture to `META-INF/manifest.xml`. Integer px ⇄ round3-cm ⇄ px is sub-pixel exact, so size is unchanged. A rotated image also gets a `draw:transform="rotate(<rad>) translate(...)"` (ODF rotate is CCW radians, ours CW degrees; the translate re-centres it on the unrotated box). A **floating** image (`wrap !== 'inline'`) instead uses `text:anchor-type="paragraph"` + a minted `<style:style style:family="graphic">` carrying `style:wrap` + `style:horizontal-pos` (image-left → `wrap="right" horizontal-pos="left"`; topBottom → `wrap="none" horizontal-pos="center"`) injected into content.xml automatic-styles.
|
||||
- **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).
|
||||
- **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>` (`utils/shapes.ts`; see `docs/architecture/frames.md`) 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).
|
||||
- **A text box's own parts, DOCX** (`applyTextBoxesDocx`): the `docx` package can't emit a DrawingML shape, so the box XML is a hand-built string — and its content needs parts the package never hears about. The pass holds the whole unzipped package, so it mints them itself: a picture becomes `word/media/tbx{n}.{ext}` plus a `Relationship` above the package's highest `rId` (Content_Types already declares the raster defaults), a list an `<w:abstractNum>`/`<w:num>` pair above its highest id, appended **before** the first `<w:num>` because every abstract definition has to precede them. The numbering ids only exist once the packed file is in hand, so the paragraphs carry a `TXBX_NUM` sentinel that the same pass substitutes.
|
||||
- **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`.
|
||||
- **Right-to-left pages**: ODF gets `style:writing-mode="rl-tb"` on the page layout (`rewriteStylesXml`), DOCX a `<w:bidi/>` in every `w:sectPr` from a post-pack pass (`applyBidiDocx`) — the package exposes only the paragraph-level flag. A **paragraph's** own direction (`textDirection.ts`) rides that same flag in DOCX and the `ParaStyle`/PBX-spec pipe in ODF, so it reaches cells and list items as well as top-level blocks.
|
||||
- **Mirror margins** (`applyMirrorMarginsDocx`, `export/docx.ts`): the `docx` package writes only the per-section `w:pgMar`, so Word's document-level `w:mirrorMargins` goes into `word/settings.xml` in the same post-pack pass; ODF's flag rides `rewriteStylesXml`. Both formats already hold left/right as the inner/outer pair, so only the flag has to travel.
|
||||
@@ -52,4 +53,5 @@ U+E009 (columns), U+E00A (date/time fields), `STY` U+E00D (paragraph styles),
|
||||
`BMS`/`BME` U+E013/U+E014 (a bookmark's range), `XRF` U+E015 (cross-references),
|
||||
`CHP` U+E016 (the chapter field), `FNT` U+E017 (footnotes/endnotes),
|
||||
`CMS`/`CME` U+E018/U+E019 (a comment's range), `SEQ` U+E01A (a caption's number),
|
||||
`TCI` U+E01B (a tracked insertion's range), `TCD` U+E01C (where a tracked deletion was).
|
||||
`TCI` U+E01B (a tracked insertion's range), `TCD` U+E01C (where a tracked deletion was),
|
||||
`TXBX_NUM` U+E01D (DOCX only: a numbering id a box's list has yet to be given).
|
||||
+142
-30
@@ -150,6 +150,14 @@ function collectComments(node: TiptapNode): void {
|
||||
// export/odt.ts): the docx library can't emit DrawingML shapes, so buildDocx swaps
|
||||
// the marker for a hand-built <w:drawing><wps:wsp> in a zip post-processing pass.
|
||||
const TBX = '';
|
||||
// Wraps the index of a numbering definition minted for a box's list; the ids only
|
||||
// become known once the packed numbering.xml is in hand.
|
||||
const TXBX_NUM = '';
|
||||
|
||||
const WP_NS = 'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"';
|
||||
const A_NS = 'http://schemas.openxmlformats.org/drawingml/2006/main';
|
||||
const PIC_NS = 'http://schemas.openxmlformats.org/drawingml/2006/picture';
|
||||
const R_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
|
||||
|
||||
// CSS font-size string ("12pt", "16px", "1.2em") → Word half-points. em is relative
|
||||
// to the 12pt body. Returns undefined when unparseable.
|
||||
@@ -692,12 +700,45 @@ function txbxRunPropsXml(marks: TiptapNode['marks'] = []): string {
|
||||
return parts.length ? `<w:rPr>${parts.join('')}</w:rPr>` : '';
|
||||
}
|
||||
|
||||
// One paragraph/heading of a text box. markerText prefixes a literal list marker
|
||||
// (in-box lists are flattened, like ODT cell lists once were); images are dropped
|
||||
// (media/rels can't be minted from a string pass).
|
||||
function txbxParagraphXml(node: TiptapNode, indentTwip = 0, markerText = ''): string {
|
||||
// The package parts a box's content needs beyond word/document.xml: a picture is a
|
||||
// media entry plus a relationship, a list a numbering definition. applyTextBoxesDocx
|
||||
// runs after packing, so it holds the whole zip and can mint all three.
|
||||
type TxbxParts = {
|
||||
media: { path: string; bytes: Uint8Array; rid: string; type: string }[];
|
||||
nums: { id: number; levels: string[] }[];
|
||||
nextRid: () => string;
|
||||
};
|
||||
|
||||
// One picture inside a box, as an as-char <wp:inline> drawing.
|
||||
function txbxImageXml(node: TiptapNode, parts: TxbxParts): string {
|
||||
const src = node.attrs?.src;
|
||||
if (typeof src !== 'string') return '';
|
||||
const decoded = decodeDataUri(src);
|
||||
if (!decoded) return '';
|
||||
const n = parts.media.length + 1;
|
||||
const ext = decoded.type === 'jpg' ? 'jpeg' : decoded.type;
|
||||
const rid = parts.nextRid();
|
||||
parts.media.push({ path: `word/media/tbx${n}.${ext}`, bytes: decoded.bytes, rid, type: ext });
|
||||
const cx = Math.round((typeof node.attrs?.width === 'number' && node.attrs.width > 0 ? node.attrs.width : 200) * EMU_PER_PX);
|
||||
const cy = Math.round((typeof node.attrs?.height === 'number' && node.attrs.height > 0 ? node.attrs.height : 150) * EMU_PER_PX);
|
||||
const alt = escapeXml(typeof node.attrs?.alt === 'string' ? node.attrs.alt : '');
|
||||
return (
|
||||
`<w:drawing><wp:inline ${WP_NS} distT="0" distB="0" distL="0" distR="0">` +
|
||||
`<wp:extent cx="${cx}" cy="${cy}"/><wp:docPr id="${9000 + n}" name="Picture ${n}" descr="${alt}"/>` +
|
||||
`<a:graphic xmlns:a="${A_NS}"><a:graphicData uri="${PIC_NS}">` +
|
||||
`<pic:pic xmlns:pic="${PIC_NS}"><pic:nvPicPr><pic:cNvPr id="${9000 + n}" name="Picture ${n}"/><pic:cNvPicPr/></pic:nvPicPr>` +
|
||||
`<pic:blipFill><a:blip r:embed="${rid}" xmlns:r="${R_NS}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>` +
|
||||
`<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm>` +
|
||||
`<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic>` +
|
||||
`</a:graphicData></a:graphic></wp:inline></w:drawing>`
|
||||
);
|
||||
}
|
||||
|
||||
// One paragraph/heading of a text box. `numPr` makes it a list item of one of the
|
||||
// numbering definitions minted for the box.
|
||||
function txbxParagraphXml(node: TiptapNode, parts: TxbxParts, indentTwip = 0, numPr = ''): string {
|
||||
const attrs = node.attrs ?? {};
|
||||
const pPr: string[] = [];
|
||||
const pPr: string[] = [numPr];
|
||||
if (node.type === 'heading') {
|
||||
const lvl = Math.min(MAX_HEADING_LEVEL, Math.max(1, Number(attrs.level) || 1));
|
||||
pPr.push(`<w:pStyle w:val="Heading${lvl}"/>`);
|
||||
@@ -706,9 +747,12 @@ function txbxParagraphXml(node: TiptapNode, indentTwip = 0, markerText = ''): st
|
||||
const ta = attrs.textAlign;
|
||||
const jc = ta === 'center' ? 'center' : ta === 'right' ? 'right' : ta === 'justify' ? 'both' : '';
|
||||
if (jc) pPr.push(`<w:jc w:val="${jc}"/>`);
|
||||
let runs = markerText ? `<w:r><w:t xml:space="preserve">${escapeXml(markerText)}</w:t></w:r>` : '';
|
||||
let runs = '';
|
||||
for (const child of node.content ?? []) {
|
||||
if (child.type === 'text' && child.text) {
|
||||
if (child.type === 'image') {
|
||||
const drawing = txbxImageXml(child, parts);
|
||||
if (drawing) runs += `<w:r>${drawing}</w:r>`;
|
||||
} else if (child.type === 'text' && child.text) {
|
||||
const rPr = txbxRunPropsXml(child.marks);
|
||||
let inner = '';
|
||||
child.text.split('\t').forEach((seg, i) => {
|
||||
@@ -723,40 +767,67 @@ function txbxParagraphXml(node: TiptapNode, indentTwip = 0, markerText = ''): st
|
||||
return `<w:p>${pPr.length ? `<w:pPr>${pPr.join('')}</w:pPr>` : ''}${runs}</w:p>`;
|
||||
}
|
||||
|
||||
// In-box lists flatten to literal-marker paragraphs (real numbering would need
|
||||
// numbering.xml references, which the post-pack string pass can't mint).
|
||||
// `mlPrefix`: parent chain ("1.2.") when inside a multilevel list, else null.
|
||||
function txbxListXml(node: TiptapNode, depth: number, mlPrefix: string | null = null, cycle: OrderedCycle = ROOT_ORDERED_CYCLE): string {
|
||||
// One <w:lvl> of a numbering definition minted for a box — the same shape
|
||||
// Numbering.ensureLevel hands the package for a body list.
|
||||
function txbxLevelXml(node: TiptapNode, depth: number, chained: boolean, cycle: OrderedCycle): string {
|
||||
const ordered = node.type === 'orderedList';
|
||||
const attr = node.attrs?.listStyleType as string | null | undefined;
|
||||
const chained = ordered && (attr === 'multilevel' || (mlPrefix !== null && !attr));
|
||||
const def = effectiveOrderedDefAt(attr === 'multilevel' ? 'decimal' : attr, cycle);
|
||||
const fmt = ordered
|
||||
? (chained ? 'decimal' : String(ORDERED_FORMAT[def.numFormat] ?? 'decimal'))
|
||||
: 'bullet';
|
||||
const text = !ordered
|
||||
? bulletCharOf(node, depth)
|
||||
: chained
|
||||
? Array.from({ length: depth + 1 }, (_, i) => `%${i + 1}.`).join('')
|
||||
: `%${depth + 1}${def.numSuffix}`;
|
||||
const start = ordered && typeof node.attrs?.start === 'number' ? node.attrs.start : 1;
|
||||
const jc = node.attrs?.markerAlign === 'right' ? 'right' : 'left';
|
||||
return (
|
||||
`<w:lvl w:ilvl="${depth}"><w:start w:val="${start}"/><w:numFmt w:val="${fmt}"/>` +
|
||||
`<w:lvlText w:val="${escapeXml(text)}"/><w:lvlJc w:val="${jc}"/>` +
|
||||
`<w:pPr><w:ind w:left="${cmToTwip((depth + 1) * LIST_LEFT_STEP_CM)}"` +
|
||||
` w:hanging="${cmToTwip(LIST_HANGING_CM)}"/></w:pPr></w:lvl>`
|
||||
);
|
||||
}
|
||||
|
||||
// A list inside a box, as a real Word list: one numbering definition per top-level
|
||||
// list, each nesting level's format filled in when first met — as the body's
|
||||
// Numbering registry does, only written straight to XML.
|
||||
function txbxListXml(
|
||||
node: TiptapNode, depth: number, parts: TxbxParts,
|
||||
num: TxbxParts['nums'][number] | null = null, chainRoot = false, cycle: OrderedCycle = ROOT_ORDERED_CYCLE,
|
||||
): string {
|
||||
const ordered = node.type === 'orderedList';
|
||||
const attr = node.attrs?.listStyleType as string | null | undefined;
|
||||
const chained = ordered && (attr === 'multilevel' || (chainRoot && !attr));
|
||||
const cChild = childCycle(cycle, attr, ordered);
|
||||
let n = typeof node.attrs?.start === 'number' ? node.attrs.start : 1;
|
||||
const def = num ?? { id: 0, levels: [] as string[] };
|
||||
if (!num) {
|
||||
def.id = parts.nums.length;
|
||||
parts.nums.push(def);
|
||||
}
|
||||
if (!def.levels[depth]) def.levels[depth] = txbxLevelXml(node, depth, chained, cycle);
|
||||
const numPr = `<w:numPr><w:ilvl w:val="${depth}"/><w:numId w:val="${TXBX_NUM}${def.id}${TXBX_NUM}"/></w:numPr>`;
|
||||
let out = '';
|
||||
for (const item of node.content ?? []) {
|
||||
if (item.type !== 'listItem') continue;
|
||||
let first = true;
|
||||
for (const child of item.content ?? []) {
|
||||
if (child.type === 'bulletList' || child.type === 'orderedList') {
|
||||
out += txbxListXml(child, depth + 1, chained ? `${mlPrefix ?? ''}${n}.` : null, cChild);
|
||||
out += txbxListXml(child, depth + 1, parts, def, chained, cChild);
|
||||
} else if (child.type === 'paragraph' || child.type === 'heading') {
|
||||
const ordinal = chained ? `${mlPrefix ?? ''}${n}. ` : `${formatOrdinal(n, def.numFormat)}${def.numSuffix} `;
|
||||
const marker = first ? (ordered ? ordinal : `${bulletCharOf(node, depth)} `) : '';
|
||||
out += txbxParagraphXml(child, cmToTwip((depth + 1) * LIST_HANGING_CM), marker);
|
||||
first = false;
|
||||
out += txbxParagraphXml(child, parts, 0, numPr);
|
||||
}
|
||||
}
|
||||
n++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function txbxContentXml(blocks: TiptapNode[]): string {
|
||||
function txbxContentXml(blocks: TiptapNode[], parts: TxbxParts): string {
|
||||
let out = '';
|
||||
for (const b of blocks) {
|
||||
if (b.type === 'paragraph' || b.type === 'heading') out += txbxParagraphXml(b);
|
||||
else if (b.type === 'bulletList' || b.type === 'orderedList') out += txbxListXml(b, 0);
|
||||
if (b.type === 'paragraph' || b.type === 'heading') out += txbxParagraphXml(b, parts);
|
||||
else if (b.type === 'bulletList' || b.type === 'orderedList') out += txbxListXml(b, 0, parts);
|
||||
}
|
||||
return out || '<w:p/>';
|
||||
}
|
||||
@@ -764,7 +835,7 @@ function txbxContentXml(blocks: TiptapNode[]): string {
|
||||
// The full <w:drawing> for one text box. Namespaces are declared inline on the
|
||||
// wp/a/wps elements so the output never depends on what the library declares on
|
||||
// the document root.
|
||||
function textBoxDrawingXml(box: TextBoxDocx, index: number): string {
|
||||
function textBoxDrawingXml(box: TextBoxDocx, index: number, parts: TxbxParts): string {
|
||||
const cx = Math.round(box.widthPx * EMU_PER_PX);
|
||||
const cy = Math.round(box.heightPx * EMU_PER_PX);
|
||||
const rot = box.rotationDeg ? ` rot="${Math.round(box.rotationDeg * 60000)}"` : '';
|
||||
@@ -782,7 +853,7 @@ function textBoxDrawingXml(box: TextBoxDocx, index: number): string {
|
||||
`<wps:cNvSpPr txBox="1"/>` +
|
||||
`<wps:spPr><a:xfrm${rot}><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm>` +
|
||||
`<a:prstGeom prst="${SHAPES[box.shapeKind].prst}"><a:avLst/></a:prstGeom>${fill}${ln}</wps:spPr>` +
|
||||
`<wps:txbx><w:txbxContent>${txbxContentXml(box.content)}</w:txbxContent></wps:txbx>` +
|
||||
`<wps:txbx><w:txbxContent>${txbxContentXml(box.content, parts)}</w:txbxContent></wps:txbx>` +
|
||||
`<wps:bodyPr rot="0" vert="horz" wrap="square" lIns="${inset}" tIns="${inset}" rIns="${inset}" bIns="${inset}" anchor="t">${autofit}</wps:bodyPr>` +
|
||||
`</wps:wsp>`;
|
||||
const graphic =
|
||||
@@ -790,9 +861,8 @@ function textBoxDrawingXml(box: TextBoxDocx, index: number): string {
|
||||
`<a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape">${wsp}</a:graphicData></a:graphic>`;
|
||||
const docPr = `<wp:docPr id="${9001 + index}" name="TextBox${index + 1}"/>`;
|
||||
const extent = `<wp:extent cx="${cx}" cy="${cy}"/>`;
|
||||
const wpNs = 'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"';
|
||||
if (box.wrap === 'inline') {
|
||||
return `<w:drawing><wp:inline ${wpNs} distT="0" distB="0" distL="0" distR="0">${extent}${docPr}${graphic}</wp:inline></w:drawing>`;
|
||||
return `<w:drawing><wp:inline ${WP_NS} distT="0" distB="0" distL="0" distR="0">${extent}${docPr}${graphic}</wp:inline></w:drawing>`;
|
||||
}
|
||||
// wrapText names the side TEXT flows on (inverse of the box side), like ODT.
|
||||
const wrapEl = box.wrap === 'topBottom'
|
||||
@@ -804,7 +874,7 @@ function textBoxDrawingXml(box: TextBoxDocx, index: number): string {
|
||||
? `<wp:posOffset>${emu(box.offsetCm)}</wp:posOffset>`
|
||||
: `<wp:align>${align}</wp:align>`;
|
||||
return (
|
||||
`<w:drawing><wp:anchor ${wpNs} distT="0" distB="0" distL="${emu(box.distCm ?? 0)}" distR="${emu(box.distCm ?? 0)}"` +
|
||||
`<w:drawing><wp:anchor ${WP_NS} distT="0" distB="0" distL="${emu(box.distCm ?? 0)}" distR="${emu(box.distCm ?? 0)}"` +
|
||||
` simplePos="0" relativeHeight="${251658240 + index}" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="0">` +
|
||||
`<wp:simplePos x="0" y="0"/>` +
|
||||
`<wp:positionH relativeFrom="margin">${posH}</wp:positionH>` +
|
||||
@@ -813,21 +883,63 @@ function textBoxDrawingXml(box: TextBoxDocx, index: number): string {
|
||||
);
|
||||
}
|
||||
|
||||
// The highest number already used by `attr` in `xml`, so a part we add gets a free one.
|
||||
function maxIdIn(xml: string, attr: RegExp): number {
|
||||
let max = 0;
|
||||
for (const m of xml.matchAll(attr)) max = Math.max(max, parseInt(m[1], 10) || 0);
|
||||
return max;
|
||||
}
|
||||
|
||||
// Post-pack pass: swap each marker paragraph in word/document.xml for its drawing.
|
||||
// The tempered pattern keeps the match inside one paragraph.
|
||||
// The tempered pattern keeps the match inside one paragraph. A box's pictures and
|
||||
// lists need parts of their own — the pass holds the whole zip, so it appends the
|
||||
// media entries, their relationships and the numbering definitions here.
|
||||
function applyTextBoxesDocx(bytes: Uint8Array, boxes: TextBoxDocx[]): Uint8Array {
|
||||
if (!boxes.length) return bytes;
|
||||
const files = unzipSync(bytes);
|
||||
const docBytes = files['word/document.xml'];
|
||||
if (!docBytes) return bytes;
|
||||
let xml = strFromU8(docBytes);
|
||||
|
||||
const relsPath = 'word/_rels/document.xml.rels';
|
||||
const rels = files[relsPath] ? strFromU8(files[relsPath]) : '';
|
||||
let nextRid = maxIdIn(rels, /Id="rId(\d+)"/g);
|
||||
const parts: TxbxParts = { media: [], nums: [], nextRid: () => `rId${++nextRid}` };
|
||||
|
||||
xml = xml.replace(
|
||||
new RegExp(`<w:p\\b[^>]*?>(?:(?!</w:p>)[\\s\\S])*?${TBX}(\\d+)${TBX}(?:(?!</w:p>)[\\s\\S])*?</w:p>`, 'g'),
|
||||
(_m, idx: string) => {
|
||||
const box = boxes[Number(idx)];
|
||||
return box ? `<w:p><w:r>${textBoxDrawingXml(box, Number(idx))}</w:r></w:p>` : '';
|
||||
return box ? `<w:p><w:r>${textBoxDrawingXml(box, Number(idx), parts)}</w:r></w:p>` : '';
|
||||
},
|
||||
);
|
||||
|
||||
const numPath = 'word/numbering.xml';
|
||||
const numXml = files[numPath] ? strFromU8(files[numPath]) : '';
|
||||
if (parts.nums.length && numXml) {
|
||||
// Both id spaces are the package's, so one free number above its highest serves
|
||||
// as the abstract id and the concrete one alike.
|
||||
const base = Math.max(maxIdIn(numXml, /w:abstractNumId="(\d+)"/g), maxIdIn(numXml, /w:numId="(\d+)"/g)) + 1;
|
||||
const abstracts = parts.nums.map((n, i) =>
|
||||
`<w:abstractNum w:abstractNumId="${base + i}"><w:multiLevelType w:val="hybridMultilevel"/>` +
|
||||
`${n.levels.filter(Boolean).join('')}</w:abstractNum>`).join('');
|
||||
const concretes = parts.nums.map((_n, i) =>
|
||||
`<w:num w:numId="${base + i}"><w:abstractNumId w:val="${base + i}"/></w:num>`).join('');
|
||||
// w:abstractNum must precede every w:num, so both go where the first w:num is.
|
||||
const at = numXml.indexOf('<w:num ');
|
||||
files[numPath] = strToU8(at < 0
|
||||
? numXml.replace('</w:numbering>', `${abstracts}${concretes}</w:numbering>`)
|
||||
: numXml.slice(0, at) + abstracts + concretes + numXml.slice(at));
|
||||
xml = xml.replace(new RegExp(`${TXBX_NUM}(\\d+)${TXBX_NUM}`, 'g'), (_m, i: string) => String(base + Number(i)));
|
||||
}
|
||||
|
||||
if (parts.media.length && rels) {
|
||||
for (const m of parts.media) files[m.path] = m.bytes as Uint8Array<ArrayBuffer>;
|
||||
files[relsPath] = strToU8(rels.replace('</Relationships>', parts.media.map((m) =>
|
||||
`<Relationship Id="${m.rid}" Type="${R_NS}/image" Target="media/${m.path.split('/').pop()}"/>`).join('') +
|
||||
'</Relationships>'));
|
||||
}
|
||||
|
||||
files['word/document.xml'] = strToU8(xml);
|
||||
const out: Record<string, [Uint8Array, { level: 6 }]> = {};
|
||||
for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 6 }];
|
||||
|
||||
@@ -1146,3 +1146,61 @@ describe('DOCX table margins (dragged outer edges)', () => {
|
||||
expect(table.attrs?.marginLeft ?? 0).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DOCX text box: a list inside it is a real list, a picture a real picture', () => {
|
||||
const doc: N = { type: 'doc', content: [
|
||||
// A body list first, so the box's numbering has to find free ids beside it.
|
||||
{ type: 'bulletList', content: [li(para('body item'))] },
|
||||
{ type: 'textBox', attrs: { width: 300, height: 200 }, content: [
|
||||
para('box text'),
|
||||
{ type: 'bulletList', content: [
|
||||
li(para('one')),
|
||||
li(para('two'), { type: 'orderedList', attrs: { start: 3 }, content: [li(para('a')), li(para('b'))] }),
|
||||
] },
|
||||
para([{ type: 'image', attrs: { src: PNG, width: 64, height: 64, alt: 'dot' } }]),
|
||||
] },
|
||||
] };
|
||||
|
||||
it('mints numbering, media and relationships of its own', async () => {
|
||||
const files = unzipSync(await buildDocx(doc, { top: 2, bottom: 2, left: 2, right: 2 } as any, 'portrait'));
|
||||
const xml = strFromU8(files['word/document.xml']);
|
||||
const numbering = strFromU8(files['word/numbering.xml']);
|
||||
const rels = strFromU8(files['word/_rels/document.xml.rels']);
|
||||
|
||||
const inBox = xml.slice(xml.indexOf('<w:txbxContent>'));
|
||||
const numIds = [...inBox.matchAll(/<w:numId w:val="(\d+)"\/>/g)].map((m) => m[1]);
|
||||
expect(numIds.length).toBe(4);
|
||||
expect(new Set(numIds).size).toBe(1);
|
||||
// The body's own list keeps its definition; the box's is a second one.
|
||||
const bodyNumId = /<w:numId w:val="(\d+)"\/>/.exec(xml)![1];
|
||||
expect(numIds[0]).not.toBe(bodyNumId);
|
||||
for (const id of new Set([...numIds, bodyNumId])) {
|
||||
expect(numbering).toContain(`<w:num w:numId="${id}">`);
|
||||
}
|
||||
// Two levels: the bullet and the ordered list nested under it, starting at 3.
|
||||
const added = numbering.slice(numbering.lastIndexOf(`<w:abstractNum w:abstractNumId="${numIds[0]}"`));
|
||||
expect(added).toContain('<w:numFmt w:val="bullet"/>');
|
||||
expect(added).toContain('<w:numFmt w:val="lowerLetter"/>');
|
||||
expect(added).toContain('<w:start w:val="3"/>');
|
||||
// No literal markers left in the runs.
|
||||
expect(inBox).not.toContain('<w:t xml:space="preserve">• ');
|
||||
|
||||
expect(Object.keys(files)).toContain('word/media/tbx1.png');
|
||||
const rid = /<a:blip r:embed="(rId\d+)"/.exec(inBox)![1];
|
||||
expect(rels).toContain(`<Relationship Id="${rid}" `);
|
||||
expect(rels).toContain('Target="media/tbx1.png"');
|
||||
});
|
||||
|
||||
it('round-trips the whole box', async () => {
|
||||
const back = importDocx(await buildDocx(doc, { top: 2, bottom: 2, left: 2, right: 2 } as any, 'portrait'));
|
||||
expect(back.warnings).toEqual([]);
|
||||
const box = (back.content.content ?? []).find((n: N) => n.type === 'textBox')!;
|
||||
const list = box.content!.find((n: N) => n.type === 'bulletList')!;
|
||||
expect(list.content![1].content![1].type).toBe('orderedList');
|
||||
expect(list.content![1].content![1].attrs.start).toBe(3);
|
||||
const img = walk(box, 'image')[0];
|
||||
expect(img.attrs.width).toBe(64);
|
||||
expect(img.attrs.alt).toBe('dot');
|
||||
expect(String(img.attrs.src).startsWith('data:image/png;base64,')).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user