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.
This commit is contained in:
Steffen Becker committed 2026-08-26 15:41:12 +00:00
1 parent c566950258
commit 3a6bc336f7
6 files changed
+103 -29

No files matched your search

+1
View File
@@ -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 `<undefined>` element.
The filename is derived from the first non-empty heading (max 50 chars, sanitized), falling back to `document.odt`.
+63 -3
View File
@@ -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(/<w:comments\b(?![^>]*xmlns:w14=)/, `<w:comments xmlns:w14="${W14_NS}"`);
// w14:paraId needs w14 declared mc:Ignorable, or a strict reader rejects the part.
xml = xml.replace(/<w:comments\b(?![^>]*xmlns:mc=)/,
'<w:comments xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"');
xml = /<w:comments\b[^>]*\bmc:Ignorable="/.test(xml)
? xml.replace(/(<w:comments\b[^>]*\bmc:Ignorable=")((?:(?!w14\b)[^"])*")/, '$1w14 $2')
: xml.replace(/<w:comments\b/, '<w:comments mc:Ignorable="w14"');
for (const c of resolved) {
// Stamp the comment's last body paragraph (Word keys the whole comment off it);
// the greedy tempered scan ends at the last <w:p before </w:comment>.
@@ -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 = /(<w:settings\b[^>]*>)([\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<string, [Uint8Array, { level: 6 }]> = {};
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)));
}
+28 -11
View File
@@ -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 ? `<svg:title>${escapeXml(img.alt)}</svg:title>` : '';
const inner = `<draw:image xlink:href="${img.path}"/>${title}`;
const inner = `<draw:image xlink:href="${img.path}" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/>${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 (
`<draw:frame draw:name="HfImage${index + 1}" ${anchor} draw:z-index="${index}"${dims}>` +
`<draw:image xlink:href="${img.path}"/>${title}</draw:frame>`
`<draw:image xlink:href="${img.path}" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/>${title}</draw:frame>`
);
}
@@ -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 `<text:time text:time-value="${toTimeValue(when)}"${fixedAttr} style:data-style-name="${styleName}">${display}</text:time>`;
// text:time-value is xsd:time/dateTime — LibreOffice writes the full dateTime;
// a PT…S duration here is a schema violation.
return `<text:time text:time-value="${toDateValue(when)}"${fixedAttr} style:data-style-name="${styleName}">${display}</text:time>`;
}
return `<text:date text:date-value="${toDateValue(when)}"${fixedAttr} style:data-style-name="${styleName}">${display}</text:date>`;
});
@@ -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 <style:columns> (not section-properties).
// text:dont-balance-text-columns sits on <style:section-properties> — the only place
// the schema admits it, and where LibreOffice reads and re-writes it (probed).
function columnsSectionStyle(cols: ColumnsExport, index: number): string {
return (
`<style:style style:name="ColSec${index + 1}" style:family="section">` +
`<style:section-properties style:editable="false">` +
`<style:columns fo:column-count="${cols.count}" fo:column-gap="${cols.gapCm}cm"` +
` text:dont-balance-text-columns="false"/>` +
`<style:section-properties style:editable="false" text:dont-balance-text-columns="false">` +
`<style:columns fo:column-count="${cols.count}" fo:column-gap="${cols.gapCm}cm"/>` +
`</style:section-properties></style:style>`
);
}
@@ -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}<text:index-entry-page-number/>` : '';
// 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) =>
`<text:${spec.el}-entry-template${levelAttr} text:style-name="${style}">` +
`<text:index-entry-link-start/><text:index-entry-text/>${pageCol}` +
`<text:index-entry-link-end/>` +
`${links ? '<text:index-entry-link-start/>' : ''}<text:index-entry-text/>${pageCol}` +
`${links ? '<text:index-entry-link-end/>' : ''}` +
`</text:${spec.el}-entry-template>`;
// 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
+9 -10
View File
@@ -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 <text:date>/<text:time> whose data style matches a known format → a live field
-4
View File
@@ -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 {
+2 -1
View File
@@ -1527,7 +1527,8 @@ describe('Leg 10: date/time fields (text:date / text:time)', () => {
check('fixed date carries text:fixed=true', /<text:date[^>]*text:fixed="true"/.test(content));
check('auto date carries text:fixed=false', /<text:date[^>]*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('<number:date-style '));
check('mints a number:time-style', content.includes('<number:time-style '));
check('declares number namespace', content.includes('xmlns:number='));