fix: a Word text box holding a picture keeps its caption
Its picture nests a second w:drawing, and the importer's subtree search found *that* one's wp:inline first: the box was read as the bare picture, so every word of the caption in it was dropped — which is what Word's own Insert Caption writes for a floating picture. The root is now the drawing's direct child. The export side gains the caption's SEQ field inside a box, so the running number stays live there too. Leg 30 covers both.
This commit is contained in:
1 parent
46165b82f7
commit
e708744147
4 files changed
+57
-5
No files matched your search
+12
-2
@@ -413,12 +413,17 @@ const DOCX_SEQ_SWITCH: Record<NoteNumFormat, string> = {
|
||||
|
||||
// A caption's running number: a SEQ field whose cached result is the rank the editor
|
||||
// resolved, so Word and LibreOffice show it before anyone updates fields.
|
||||
function sequenceField(node: TiptapNode): SimpleField {
|
||||
function sequenceFieldParts(node: TiptapNode): { instr: string; text: string } {
|
||||
const a = node.attrs ?? {};
|
||||
const format = (typeof a.format === 'string' && a.format ? a.format : '1') as NoteNumFormat;
|
||||
const number = typeof a.number === 'number' && a.number > 0 ? a.number : 1;
|
||||
const name = DOCX_SEQ_NAME[seqCategoryOf(a.category as string)];
|
||||
return new SimpleField(`SEQ ${name} \\* ${DOCX_SEQ_SWITCH[format] ?? 'ARABIC'}`, formatOrdinal(number, format));
|
||||
return { instr: `SEQ ${name} \\* ${DOCX_SEQ_SWITCH[format] ?? 'ARABIC'}`, text: formatOrdinal(number, format) };
|
||||
}
|
||||
|
||||
function sequenceField(node: TiptapNode): SimpleField {
|
||||
const { instr, text } = sequenceFieldParts(node);
|
||||
return new SimpleField(instr, text);
|
||||
}
|
||||
|
||||
// A cross-reference: a REF/PAGEREF field with the resolved text as its cached result,
|
||||
@@ -784,6 +789,11 @@ function txbxParagraphXml(node: TiptapNode, parts: TxbxParts, indentTwip = 0, nu
|
||||
if (child.type === 'image') {
|
||||
const drawing = txbxImageXml(child, parts);
|
||||
if (drawing) runs += `<w:r>${drawing}</w:r>`;
|
||||
} else if (child.type === 'sequenceField') {
|
||||
// A caption inside a frame keeps its running number as a field, like any other.
|
||||
const { instr, text } = sequenceFieldParts(child);
|
||||
runs += `<w:fldSimple w:instr="${escapeXml(instr)}"><w:r>` +
|
||||
`${txbxRunPropsXml(child.marks)}<w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r></w:fldSimple>`;
|
||||
} else if (child.type === 'text' && child.text) {
|
||||
const rPr = txbxRunPropsXml(child.marks);
|
||||
let inner = '';
|
||||
|
||||
@@ -20,7 +20,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
|
||||
- **A run is measured against the block's own size**, not the yardstick's: a list item whose style sets 11pt would otherwise drop every 12pt run as "the style supplies it" and render it at 11 (`convertParaLike`, `runDefaults`).
|
||||
- **The yardstick is the style the block can name.** Only a **body** block carries a `styleName`, so a paragraph in a cell or a text box is measured against the *default* paragraph style instead of its own — otherwise everything its style provides is suppressed as "the style will supply it" and nothing ever does (a Word caption in a text box arrived without its italic or its colour).
|
||||
- **Default suppression** keeps round trips (editor → LibreOffice → editor) from accreting explicit attrs: values matching the editor's defaults (12pt body, `HEADING_STYLE_OVERRIDES` sizes/margins, **0 paragraph spacing**, list margin 0, Times New Roman/Liberation Serif, `#000000` text) are imported as `null`/no mark, with small tolerances for producer unit rounding (pt↔cm↔twips). This is why the editor's defaults must equal Word's/LibreOffice's (below) — an editor-only default is indistinguishable from a failed style resolution and silently lands in every imported document.
|
||||
- **Mapping:** `text:h` → heading (level clamped to `MAX_HEADING_LEVEL`), per-depth `text:list-level-style-*` defs decide bullet vs ordered and `listStyleType` (reverse lookup `orderedTypeFromFormat`), tables handle covered cells / `number-columns-repeated` / header rows / colspans / cell shading (`StyleResolver.cellBackgroundColor` → `backgroundColor`) / cell borders (`StyleResolver.cellBorders` + `borderAttrFromOdf` → the per-side border attrs) / table margins (`tableMargins` reads the table style's `fo:margin-*`, `style:width`/`style:rel-width` and `table:align` against `Ctx.contentWidthCm` — so a table narrower than the text width stays narrow instead of being stretched; DOCX does the same from `w:tblInd` + the `w:tblGrid`/`w:tblW` width), `text:s`/`text:tab`/`text:line-break` become spaces/`\t`/`hardBreak`, `draw:frame`/`draw:image` → an `image` node (`convertFrame`: bytes read from the zip's `Pictures/` entry into a data-URI, `svg:width`/`-height` cm → px, `svg:title` → `alt`, `draw:transform` rotate → `rotation`; a non-`as-char` anchor or a graphic style's `style:wrap` → floating `wrap` mode via `StyleResolver.graphicProps` — an **explicit `as-char` anchor is always inline**: LibreOffice's named Graphics style carries a `style:wrap` that would otherwise float every inherited frame), text fields keep their stored value. **Text boxes / shapes** (`convertDrawElement`): `draw:frame`+`draw:text-box` → a `textBox` node (`convertTextBoxFrame`: height = `fo:min-height` else `svg:height`; fill/stroke read straight from `graphicProps`' `draw:fill(-color)`/`draw:stroke`/`svg:stroke-*`, values equal to the editor defaults suppressed, none → explicit `null`); `draw:rect`/`draw:ellipse`/`draw:custom-shape` with a rect/round-rectangle/ellipse preset → a `textBox` with the matching `shapeKind` (`convertShape`; other presets → "Unsupported shapes were removed"). A box is a block node but its frame sits inside a `text:p`, so converted boxes ride `Ctx.pendingBlocks` and `convertBlocks` flushes them after the anchor paragraph (dropping our own export's empty anchor); in cells/other boxes their blocks are unwrapped in place with a warning. The DOCX importer mirrors all of this (`convertWpsShape` for DrawingML `wps:wsp` — probed before the image `a:blip` path, `convertPict` for legacy VML; Word's `mc:AlternateContent` uses only the `mc:Choice` branch so the VML fallback can't double-import).
|
||||
- **Mapping:** `text:h` → heading (level clamped to `MAX_HEADING_LEVEL`), per-depth `text:list-level-style-*` defs decide bullet vs ordered and `listStyleType` (reverse lookup `orderedTypeFromFormat`), tables handle covered cells / `number-columns-repeated` / header rows / colspans / cell shading (`StyleResolver.cellBackgroundColor` → `backgroundColor`) / cell borders (`StyleResolver.cellBorders` + `borderAttrFromOdf` → the per-side border attrs) / table margins (`tableMargins` reads the table style's `fo:margin-*`, `style:width`/`style:rel-width` and `table:align` against `Ctx.contentWidthCm` — so a table narrower than the text width stays narrow instead of being stretched; DOCX does the same from `w:tblInd` + the `w:tblGrid`/`w:tblW` width), `text:s`/`text:tab`/`text:line-break` become spaces/`\t`/`hardBreak`, `draw:frame`/`draw:image` → an `image` node (`convertFrame`: bytes read from the zip's `Pictures/` entry into a data-URI, `svg:width`/`-height` cm → px, `svg:title` → `alt`, `draw:transform` rotate → `rotation`; a non-`as-char` anchor or a graphic style's `style:wrap` → floating `wrap` mode via `StyleResolver.graphicProps` — an **explicit `as-char` anchor is always inline**: LibreOffice's named Graphics style carries a `style:wrap` that would otherwise float every inherited frame), text fields keep their stored value. **Text boxes / shapes** (`convertDrawElement`): `draw:frame`+`draw:text-box` → a `textBox` node (`convertTextBoxFrame`: height = `fo:min-height` else `svg:height`; fill/stroke read straight from `graphicProps`' `draw:fill(-color)`/`draw:stroke`/`svg:stroke-*`, values equal to the editor defaults suppressed, none → explicit `null`); `draw:rect`/`draw:ellipse`/`draw:custom-shape` with a rect/round-rectangle/ellipse preset → a `textBox` with the matching `shapeKind` (`convertShape`; other presets → "Unsupported shapes were removed"). A box is a block node but its frame sits inside a `text:p`, so converted boxes ride `Ctx.pendingBlocks` and `convertBlocks` flushes them after the anchor paragraph (dropping our own export's empty anchor); in cells/other boxes their blocks are unwrapped in place with a warning. The DOCX importer mirrors all of this — its drawing root is the `w:drawing`'s **direct** `wp:inline`/`wp:anchor` child, since a box holding a picture nests a second drawing and a subtree search reads *its* root instead, dropping the box and the caption in it (`convertWpsShape` for DrawingML `wps:wsp` — probed before the image `a:blip` path, `convertPict` for legacy VML; Word's `mc:AlternateContent` uses only the `mc:Choice` branch so the VML fallback can't double-import).
|
||||
- **Image formats (`import/imageFormats.ts`, shared by both importers):** `<img>` only renders a fixed set of formats, so `imageDataUrl(bytes, path)` resolves the mime by extension **then** magic-number (mislabelled/extensionless entries still work) and returns `null` for anything unrenderable — the importer then skips it with a specific "format the browser can't display" warning instead of a broken `<img>`. A frame's multiple `<draw:image>` alternatives (ODF) and a Word `a:blip`'s `svgBlip` alternative are probed for the first *displayable* one, and `<draw:a>`-wrapped frames are unwrapped (the hyperlink dropped). **Non-web formats we can decode client-side** (currently **TIFF**) are converted to PNG up front by `convertUnsupportedImages(bytes)` — an async pass `applyImport` runs before the synchronous import, passing a `path → PNG data-URI` map (`ConvertedImages`) into `importOdt`/`importDocx` (default empty, so tests stay sync). The decoder (`utif2`) is a **dynamic `import()`** → its own lazy chunk, loaded only when a convertible image is actually present, so ordinary docs pay nothing. **EMF** goes through the same lazy pre-pass into an SVG picture (`import/emf.ts`, below). **SVM/WMF** still have no decoder (SVM is StarOffice-proprietary, WMF a different 16-bit record format) → skipped with the format warning; LibreOffice/WASM conversion was ruled out (no backend; WASM LO is ~300MB). A **CMYK** JPEG loses its ICC profile (`stripCmykIccProfile`): Chromium colour-manages through it, LibreOffice and Word convert naively, and the file's own colours are the ones every word processor shows (measured on the contract fixture's logo: blue (0,80,131) → (0,77,183) against LibreOffice's (0,32,183), black (57,53,54) → (11,11,18) against (0,0,0)).
|
||||
- **EMF (`import/emf.ts`)**: `emfToSvg(bytes)` replays the record stream into SVG — window/viewport plus world transform mapped per point, a DC stack (`SAVEDC`/`RESTOREDC`), pens/brushes/fonts (incl. the stock objects), the 16- and 32-bit poly records, path brackets, `RECTANGLE`/`ELLIPSE`, `EXTTEXTOUT` (its per-character advances become `textLength`, so labels keep their width) and `STRETCHDIBITS` (the DIB re-wrapped as a .bmp data-URI). **Text is stored as raw code units**, so a symbol font leaves lone surrogates behind — they must be stripped or `encodeURIComponent` throws and the whole picture is lost. Unknown records are skipped; a file that yields nothing returns null and keeps the placeholder.
|
||||
- **Charts (`import/chart.ts`, DOCX)**: `word/charts/chartN.xml` is parsed and **drawn as an SVG picture** of the frame's own size — series values from the `numCache` the producer left, colours from the named theme accent (`themeAccents`), title/axis titles/gridlines, and the value axis' own `c:scaling` bounds where the file fixes them. Bar (incl. stacked and `barDir="bar"`), line, area, scatter and pie; anything else keeps the placeholder. Read-only — the editor has no chart object, so a re-export carries the picture, not the chart. **ODF** charts go through the same drawing (`odfChartDataUrl`, `convertChartFrame`): `<chart:chart>` in the `draw:object`'s sub-document, series values read out of its own `local-table` by cell range, colours from the series' `draw:fill-color`, bounds from the y-axis style's `chart:maximum`/`-minimum`.
|
||||
|
||||
@@ -1655,9 +1655,14 @@ function drawingIsFloating(el: Element): boolean {
|
||||
}
|
||||
|
||||
function convertDrawing(drawing: Element, ctx: Ctx): Node | null {
|
||||
const anchor = drawing.getElementsByTagNameNS(WP, 'anchor')[0];
|
||||
const root = drawing.getElementsByTagNameNS(WP, 'inline')[0] ?? anchor;
|
||||
// The drawing's own root, as a direct child: a text box holding a picture nests a
|
||||
// second drawing, and a subtree search finds *its* wp:inline first — the box, and
|
||||
// the caption in it, are then read as the bare picture.
|
||||
const root = Array.from(drawing.children).find(
|
||||
(c) => c.namespaceURI === WP && (c.localName === 'inline' || c.localName === 'anchor'),
|
||||
);
|
||||
if (!root) return null;
|
||||
const anchor = root.localName === 'anchor' ? root : undefined;
|
||||
// A wordprocessingShape (text box / preset shape) has no blip — convert it first.
|
||||
const wsp = root.getElementsByTagNameNS(WPS, 'wsp')[0];
|
||||
if (wsp) return convertWpsShape(wsp, root, !!anchor, ctx);
|
||||
|
||||
@@ -2505,3 +2505,40 @@ describe('Leg 28: a cell formula (ODT + DOCX)', () => {
|
||||
check('it comes back as the stored formula', formulaOf(res) === 'SUM(ABOVE)', formulaOf(res));
|
||||
});
|
||||
});
|
||||
|
||||
// A picture and its caption in one frame — LibreOffice's own arrangement for a
|
||||
// captioned figure, and what a caption that stays with its picture would build.
|
||||
describe('Leg 30: a figure frame (picture + caption in one box)', () => {
|
||||
const SEQ: N = { type: 'sequenceField', attrs: { category: 'figure', format: '1', number: 1 } };
|
||||
const frame = TBX(
|
||||
{ width: 240, height: 190, wrap: 'topBottom', wrapAlign: 'center', fillColor: null, strokeColor: null },
|
||||
P(null, IMGN(240, 160, 'pic')),
|
||||
P({ styleName: 'Caption' }, T('Figure '), SEQ, T(': framed')),
|
||||
);
|
||||
const doc: N = { type: 'doc', content: [P(null, T('body')), frame] };
|
||||
const boxOf = (res: N): N => (res.content.content ?? []).find((n: N) => n.type === 'textBox');
|
||||
const shapeOf = (box: N): string =>
|
||||
(box?.content ?? []).map((b: N) => (b.content ?? []).map((c: N) => c.type).join('+')).join('|');
|
||||
|
||||
it('ODT: the picture rides inside the box, caption and counter with it', async () => {
|
||||
const bytes = await buildOdt(doc, margins);
|
||||
const xml = strFromU8(unzipSync(bytes)['content.xml']);
|
||||
const box = xml.match(/<draw:text-box[\s\S]*?<\/draw:text-box>/)?.[0] ?? '';
|
||||
check('the picture is inside the text box', box.includes('<draw:image'), box.slice(0, 200));
|
||||
check('so is the counter', box.includes('text:name="Illustration"'), box.slice(0, 400));
|
||||
const back = boxOf(importOdt(bytes));
|
||||
check('box comes back whole', shapeOf(back) === 'image|text+sequenceField+text', shapeOf(back));
|
||||
});
|
||||
|
||||
it('DOCX: the drawing inside the box does not swallow it', async () => {
|
||||
const bytes = await buildDocx(doc, margins);
|
||||
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
|
||||
const box = xml.match(/<w:txbxContent>[\s\S]*?<\/w:txbxContent>/)?.[0] ?? '';
|
||||
check('the picture is inside the box', box.includes('<w:drawing>'), box.slice(0, 200));
|
||||
check('the counter stays a field', box.includes('SEQ Figure'), box.slice(0, 600));
|
||||
// A nested drawing's own wp:inline used to be read as the outer one's root, which
|
||||
// dropped the box and every word of the caption.
|
||||
const back = boxOf(importDocx(bytes));
|
||||
check('box comes back whole', shapeOf(back) === 'image|text+sequenceField+text', shapeOf(back));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user