From 3a6bc336f7c1b867670bc38352cfefa6a34ad4c3 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 26 Aug 2026 15:41:12 +0000 Subject: [PATCH] fix: schema-valid ODF and DOCX output ODF: every part declares 1.3 (the version LibreOffice writes; header-first is a 1.3 element), draw:image gains the mandatory xlink:type trio, text:time-value becomes an xsd dateTime (import still reads the legacy duration), index-entry-link-start/-end stay in TOC templates only, and dont-balance-text-columns moves onto style:section-properties (probed: LO reads and re-writes it there). DOCX: a final pass re-sorts w:settings into the CT_Settings sequence, and comments.xml declares w14 mc:Ignorable. --- src/lib/export/CLAUDE.md | 1 + src/lib/export/docx.ts | 66 +++++++++++++++++++++++++++++++++++++-- src/lib/export/odt.ts | 39 ++++++++++++++++------- src/lib/import/odt.ts | 19 ++++++----- src/lib/utils/dateTime.ts | 4 --- tests/roundtrip.test.ts | 3 +- 6 files changed, 103 insertions(+), 29 deletions(-) diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index 51897d7..2416f7b 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -52,6 +52,7 @@ fallback download shows a one-time hint (`edentext-download-hint`) that the brow - **`collapseRunWhitespace`** — strips the bare `\n` separators odf-kit inserts between runs inside a paragraph (they'd otherwise collapse to spurious spaces mid-word). - **`rewriteStylesXml`** — `style:page-usage="mirrored"` on the page layout when the margins mirror (the left/right pair is already the inner/outer one), default font Liberation Serif → Times New Roman, heading sizes/margins → the editor's values, Standard's `fo:margin-bottom` → 0 (odf-kit emits 0.212cm; every paragraph and list item inherits it, and the editor has no paragraph spacing — see below). - **`normalizeColor`** — coerces colors to `#RRGGBB` (ODF requirement; rejects/normalizes `rgb()` and short hex). +- **Schema conformance** (guarded by `tests/schema-validation.test.ts`; LibreOffice forgives all of this, Word's strict reader does not): `applyOdfVersion` stamps every ODF part **1.3** — the version LibreOffice writes, and the first with `style:header-first` — over odf-kit's 1.2. `draw:image` carries the `xlink:type/show/actuate` trio (`xlink:type` is mandatory beside `xlink:href`); `text:time-value` is an xsd dateTime, never a `PT…S` duration (the ODT importer still reads the legacy duration); `index-entry-link-start/-end` only in TOC entry templates; `text:dont-balance-text-columns` on `style:section-properties`. DOCX: `orderDocxSettings` (last pass) re-sorts `w:settings` children into the fixed CT_Settings sequence the prepend-passes scramble; `w14:paraId` in comments.xml requires `mc:Ignorable="w14"` on the root; `styleXmlComponent` unwraps `ImportedXmlComponent.fromXmlString`'s nameless wrapper, which would serialize as a literal `` element. The filename is derived from the first non-empty heading (max 50 chars, sanitized), falling back to `document.odt`. diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index e036485..adcee5c 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -1524,6 +1524,12 @@ function applyCommentsResolvedDocx(bytes: Uint8Array): Uint8Array { const paraId = (id: number) => (0x10000000 + id).toString(16).toUpperCase().padStart(8, '0'); let xml = comments.replace(/]*xmlns:w14=)/, `]*xmlns:mc=)/, + ']*\bmc:Ignorable="/.test(xml) + ? xml.replace(/(]*\bmc:Ignorable=")((?:(?!w14\b)[^"])*")/, '$1w14 $2') + : xml.replace(/. @@ -1548,6 +1554,60 @@ function applyCommentsResolvedDocx(bytes: Uint8Array): Uint8Array { return zipSync(out); } +// CT_Settings fixes its children's sequence (extracted from wml.xsd); several passes +// prepend into word/settings.xml, so the final pass re-sorts the part into this order. +const SETTINGS_ORDER = ['writeProtection', 'view', 'zoom', 'removePersonalInformation', + 'removeDateAndTime', 'doNotDisplayPageBoundaries', 'displayBackgroundShape', + 'printPostScriptOverText', 'printFractionalCharacterWidth', 'printFormsData', + 'embedTrueTypeFonts', 'embedSystemFonts', 'saveSubsetFonts', 'saveFormsData', + 'mirrorMargins', 'alignBordersAndEdges', 'bordersDoNotSurroundHeader', + 'bordersDoNotSurroundFooter', 'gutterAtTop', 'hideSpellingErrors', + 'hideGrammaticalErrors', 'activeWritingStyle', 'proofState', 'formsDesign', + 'attachedTemplate', 'linkStyles', 'stylePaneFormatFilter', 'stylePaneSortMethod', + 'documentType', 'mailMerge', 'revisionView', 'trackRevisions', 'doNotTrackMoves', + 'doNotTrackFormatting', 'documentProtection', 'autoFormatOverride', 'styleLockTheme', + 'styleLockQFSet', 'defaultTabStop', 'autoHyphenation', 'consecutiveHyphenLimit', + 'hyphenationZone', 'doNotHyphenateCaps', 'showEnvelope', 'summaryLength', + 'clickAndTypeStyle', 'defaultTableStyle', 'evenAndOddHeaders', 'bookFoldRevPrinting', + 'bookFoldPrinting', 'bookFoldPrintingSheets', 'drawingGridHorizontalSpacing', + 'drawingGridVerticalSpacing', 'displayHorizontalDrawingGridEvery', + 'displayVerticalDrawingGridEvery', 'doNotUseMarginsForDrawingGridOrigin', + 'drawingGridHorizontalOrigin', 'drawingGridVerticalOrigin', 'doNotShadeFormData', + 'noPunctuationKerning', 'characterSpacingControl', 'printTwoOnOne', + 'strictFirstAndLastChars', 'noLineBreaksAfter', 'noLineBreaksBefore', + 'savePreviewPicture', 'doNotValidateAgainstSchema', 'saveInvalidXml', + 'ignoreMixedContent', 'alwaysShowPlaceholderText', 'doNotDemarcateInvalidXml', + 'saveXmlDataOnly', 'useXSLTWhenSaving', 'saveThroughXslt', 'showXMLTags', + 'alwaysMergeEmptyNamespace', 'updateFields', 'hdrShapeDefaults', 'footnotePr', + 'endnotePr', 'compat', 'docVars', 'rsids', 'mathPr', 'attachedSchema', + 'themeFontLang', 'clrSchemeMapping', 'doNotIncludeSubdocsInStats', + 'doNotAutoCompressPictures', 'forceUpgrade', 'captions', 'readModeInkLockDown', + 'smartTagType', 'shapeDefaults', 'doNotEmbedSmartTags', 'decimalSymbol', + 'listSeparator']; + +// Post-pack pass (runs last): sort w:settings children into the schema sequence. +function orderDocxSettings(bytes: Uint8Array): Uint8Array { + const files = unzipSync(bytes); + const setBytes = files['word/settings.xml']; + if (!setBytes) return bytes; + const xml = strFromU8(setBytes); + const m = /(]*>)([\s\S]*)(<\/w:settings>)/.exec(xml); + if (!m) return bytes; + const children = m[2].match(/<((?:w|m):\w+)\b(?:[^>]*\/>|[^>]*>[\s\S]*?<\/\1>)/g) ?? []; + // Only reorder what the tokenizer accounted for completely. + if (children.join('') !== m[2]) return bytes; + const rank = (c: string) => { + const i = SETTINGS_ORDER.indexOf(/^<[wm]+:(\w+)/.exec(c)?.[1] ?? ''); + return i < 0 ? SETTINGS_ORDER.length : i; + }; + const sorted = [...children].sort((a, b) => rank(a) - rank(b)); + if (sorted.join('') === m[2]) return bytes; + files['word/settings.xml'] = strToU8(xml.replace(m[0], m[1] + sorted.join('') + m[3])); + const out: Record = {}; + for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 6 }]; + return zipSync(out); +} + // Post-pack pass: Word's mirror margins are a document setting (w:mirrorMargins in // word/settings.xml); the docx lib writes only the per-section w:pgMar, where left and // right are already the inner/outer pair the editor holds. @@ -2566,8 +2626,8 @@ export async function buildDocx( const bidi = applyNoHyphensDocx(rtl ? applyBidiDocx(mirrored) : mirrored); const dims = pageDimsCm(pageFormat, orientation); const marked = applyFoldMarksDocx(bidi, foldMarks, dims.w * 10); - if (isEmptyPageDecor(decor)) return marked; + if (isEmptyPageDecor(decor)) return orderDocxSettings(marked); const pt = (cm: number) => (cm / 2.54) * 72; - return applyPageDecorDocx(marked, decor, - pt(dims.w - margins.left - margins.right), pt(dims.h - margins.top - margins.bottom)); + return orderDocxSettings(applyPageDecorDocx(marked, decor, + pt(dims.w - margins.left - margins.right), pt(dims.h - margins.top - margins.bottom))); } diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 70ee2d2..7eaa092 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -36,7 +36,7 @@ import { citationText, isBibType } from '../editor/extensions/bibliographyEntry' import { isCitationStyle, rowTemplate, type CitationStyle } from '../utils/citationStyle'; import { DEFAULT_BULLET_CYCLE, defaultBulletChar } from '../utils/bulletListTypes'; import { CELL_FORMAT_SPECS, currencyParts, datePattern, isCellFormat, type CellFormat } from '../utils/cellFormat'; -import { findFormat, renderFormat, odfNumberStyle, toDateValue, toTimeValue, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT, type DtFormat } from '../utils/dateTime'; +import { findFormat, renderFormat, odfNumberStyle, toDateValue, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT, type DtFormat } from '../utils/dateTime'; import { parseLatex } from '../math/latex'; import { mathmlDocument } from '../math/mathml'; @@ -4015,7 +4015,7 @@ function imageFrameXml(img: ImageExport, index: number): string { (img.widthCm ? ` svg:width="${img.widthCm}cm"` : '') + (img.heightCm ? ` svg:height="${img.heightCm}cm"` : ''); const title = img.alt ? `${escapeXml(img.alt)}` : ''; - const inner = `${title}`; + const inner = `${title}`; const floats = img.anchorPage != null || img.wrap !== 'inline'; const anchor = img.anchorPage != null ? ` text:anchor-type="page" text:anchor-page-number="${img.anchorPage}"` @@ -4047,7 +4047,7 @@ function hfImageFrameXml(img: ImageExport, index: number): string { ` svg:x="${img.wrapOffsetCm ?? 0}cm" svg:y="${img.wrapOffsetYCm ?? 0}cm"`; return ( `` + - `${title}` + `${title}` ); } @@ -4211,7 +4211,9 @@ function applyDateTimeFields(odtBytes: Uint8Array, fields: DateTimeFieldExport[] const styleName = styleFor(fmt); const fixedAttr = ` text:fixed="${field.fixed ? 'true' : 'false'}"`; if (fmt.kind === 'time') { - return `${display}`; + // text:time-value is xsd:time/dateTime — LibreOffice writes the full dateTime; + // a PT…S duration here is a schema violation. + return `${display}`; } return `${display}`; }); @@ -4377,13 +4379,13 @@ function applyTextBoxes(odtBytes: Uint8Array, boxes: TextBoxExport[]): Uint8Arra } // Section style for a multi-column region: balanced columns with a uniform gap. -// text:dont-balance-text-columns sits on (not section-properties). +// text:dont-balance-text-columns sits on — the only place +// the schema admits it, and where LibreOffice reads and re-writes it (probed). function columnsSectionStyle(cols: ColumnsExport, index: number): string { return ( `` + - `` + - `` + + `` + + `` + `` ); } @@ -4524,10 +4526,13 @@ function tocXml(toc: TocExport, index: number, bibTypes: string[]): string { // An index with no page numbers has nothing to lead to either, so the stop goes with // them — that pair is what LibreOffice's own dialog switches off together. const pageCol = toc.pageNumbers ? `${stop}` : ''; + // index-entry-link-start/-end are admitted in table-of-content templates only — + // in the figure/table/alphabetical templates they are a schema violation. + const links = toc.kind === 'toc'; const entry = (levelAttr: string, style: string) => `` + - `${pageCol}` + - `` + + `${links ? '' : ''}${pageCol}` + + `${links ? '' : ''}` + ``; // An alphabetical index is fed by its marks, is single-level, and merges the pages of // a term the reader marked more than once — LibreOffice's text:combine-entries. @@ -5010,7 +5015,19 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU // Sections past the first get their own master page, which is where ODF keeps a // section's header/footer; the SEC-marked block points at it. const withSections = applySectionMasterPages(withWatermark, hf?.sections ?? [], hf?.pageCount ?? 1, margins, pageFormat, orientation); - return zipFinal(applyDocProperties(applyPageNumberStart(applySpacingModel(withSections, spacingModel), pageNumbering.start), props)); + return zipFinal(applyOdfVersion(applyDocProperties(applyPageNumberStart(applySpacingModel(withSections, spacingModel), pageNumbering.start), props))); +} + +// The package declares ODF 1.3 in every part — the version LibreOffice writes, and +// the first with style:header-first/-footer-first. odf-kit stamps 1.2. +function applyOdfVersion(odtBytes: Uint8Array): Uint8Array { + const files = unzipSync(odtBytes); + for (const name of ['content.xml', 'styles.xml', 'meta.xml', 'settings.xml', 'META-INF/manifest.xml']) { + const bytes = files[name]; + if (!bytes) continue; + files[name] = strToU8(strFromU8(bytes).replace(/((?:office|manifest):version=")1\.2(")/, '$11.3$2')); + } + return rezipOdt(files); } // The document's first page number. ODF has no document-level start: LibreOffice puts diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 09238b0..cdf8d5a 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -1872,18 +1872,17 @@ function parseNumberStyleTokens(styleEl: Element): Token[] { return toks; } -// ISO local datetime for the node's `value`: a date-value is kept; a time-value -// (PThHmMsS) is placed on today's date (only its time part is rendered). +// ISO local datetime for the node's `value`: a date-value or dateTime time-value is +// kept; a legacy PThHmMsS time-value is placed on today's date (only its time renders). function fieldValue(kind: 'date' | 'time', raw: string | null): string { - if (kind === 'date') { - const d = raw ? new Date(raw) : null; - return d && !isNaN(d.getTime()) ? raw! : ''; + const m = kind === 'time' && raw ? /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/.exec(raw) : null; + if (m) { + const now = new Date(); + now.setHours(Number(m[1] ?? 0), Number(m[2] ?? 0), Number(m[3] ?? 0), 0); + return toDateValue(now); } - const m = raw ? /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/.exec(raw) : null; - if (!m) return ''; - const now = new Date(); - now.setHours(Number(m[1] ?? 0), Number(m[2] ?? 0), Number(m[3] ?? 0), 0); - return toDateValue(now); + const d = raw ? new Date(raw) : null; + return d && !isNaN(d.getTime()) ? raw! : ''; } // A / whose data style matches a known format → a live field diff --git a/src/lib/utils/dateTime.ts b/src/lib/utils/dateTime.ts index af27eb9..aa89822 100644 --- a/src/lib/utils/dateTime.ts +++ b/src/lib/utils/dateTime.ts @@ -112,10 +112,6 @@ export function toDateValue(d: Date): string { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`; } -export function toTimeValue(d: Date): string { - return `PT${pad2(d.getHours())}H${pad2(d.getMinutes())}M${pad2(d.getSeconds())}S`; -} - // --- ODF number style -------------------------------------------------------- function escNumberText(s: string): string { diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 1e3a688..6dfcf7f 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -1527,7 +1527,8 @@ describe('Leg 10: date/time fields (text:date / text:time)', () => { check('fixed date carries text:fixed=true', /]*text:fixed="true"/.test(content)); check('auto date carries text:fixed=false', /]*text:fixed="false"/.test(content)); check('date-value present', content.includes('text:date-value="2026-07-08T14:30:45"')); - check('time-value present', content.includes('text:time-value="PT14H30M45S"')); + // xsd dateTime, as LibreOffice writes it — a PT…S duration is a schema violation. + check('time-value present', content.includes('text:time-value="2026-07-08T14:30:45"')); check('mints a number:date-style', content.includes('