feat: placeholder fields (inline atom + ODF/DOCX round trip)

An inline atom drawn gray as ‹label›: a click selects the whole field,
typing replaces it, Tab/Shift-Tab jump between fields. Exports to ODF
<text:placeholder> (PLH sentinel U+E022) and a DOCX w:sdt content
control tagged edentext-placeholder; both importers read it back, the
export-minted gray stripped. Covered in roundtrip Leg 31 + fuzzDoc.
This commit is contained in:
Steffen Becker committed 2026-08-24 16:16:24 +00:00
1 parent ca29ff780b
commit 0753caa5a9
12 files changed
+290 -6

No files matched your search

+4
View File
@@ -53,6 +53,7 @@ import { TableStyle } from './extensions/tableStyle';
import { TrailingNode } from './extensions/trailingNode';
import { Image } from './extensions/image';
import { DateTimeField } from './extensions/dateTimeField';
import { PlaceholderField } from './extensions/placeholderField';
import { SequenceField } from './extensions/caption';
import { IndexEntry } from './extensions/indexEntry';
import { BibliographyEntry } from './extensions/bibliographyEntry';
@@ -120,6 +121,9 @@ export const extensions = [
// Inline date/time field (fixed or auto-updating); round-trips to ODF
// <text:date>/<text:time> and DOCX DATE/TIME fields.
DateTimeField,
// A template placeholder the user clicks and types over; round-trips to ODF
// <text:placeholder> and a tagged DOCX content control.
PlaceholderField,
// A caption's running number, one counter per category in document order. Round-trips
// to an ODF <text:sequence> and a DOCX SEQ field.
SequenceField,
+1
View File
@@ -31,6 +31,7 @@ Five topics are large enough to have their own deep-dive — read the file befor
- **`link.ts`** — hyperlink mark; owns `Mod-k`, which fires the event `Toolbar.svelte` listens for to open the link dialog. A `#name` href targets a bookmark: `Editor.svelte`'s `handleClick` scrolls to it instead of opening a tab. The `plain` attr (`data-plain`, `editor.css`) suppresses the editor's own blue: Word paints a link through its **Hyperlink character style**, which arrives as an ordinary mark, so every DOCX link is plain and one carrying no style is drawn like the text around it — while LibreOffice paints every ODF `text:a` styled or not (probed), so those never are. The DOCX exporter paints `#0563C1` only on a non-plain link, which is how a round trip tells its own links from a file's.
- **`comment.ts`** — an annotation on a range of text, a mark like `bookmark`: the whole comment (author, date, body, resolved) rides its attrs, so two runs of one comment merge back together and no second store is needed. `commentRanges(doc)` walks every range (an id split by a paragraph boundary yields several, which is why the commands walk all of them), `comments(doc)` keeps one per id for `CommentsPane.svelte`. A point comment — LibreOffice's, made with nothing selected, written with no `office:name` and no end element — attaches on import to the run before it, else the one after: the editor's comment is a mark and needs a range.
- **`trackChanges.ts`** — the `insertion`/`deletion` marks plus the recording plugin. `appendTransaction` marks whatever a transaction added, and `adjacentInsertion` reuses the id of the run just before it when the author matches, so a typed sentence is one change and not one per keystroke. A delete never reaches the document: `filterTransaction` rejects it and dispatches a mark pass instead (the author's own pending insertion is removed for real, as both word processors do). A **replacement** — typing or pasting over a selection — goes the same way (`replacedRange`), the new content landing after the struck-out text it replaces; recorded as the insertion alone, its other half was simply dropped and a reject lost the original. Every transaction of our own carries the `RECORDING` meta — accept/reject included, or its `tr.delete` would be read as a fresh user deletion and marked up again. **Loading a document carries it too** (`App.svelte`'s `loadContent`, both for a new document and for an import): a `setContent` is one transaction that inserts the whole file, so without the meta an opened document arrives wholly marked as the reader's own insertion, underlined in the author colour. Both word processors record what is typed after an open, never the file. `revisions(doc)` merges adjacent runs of one id, so an export writes one change per id. The **author's colour** is a decoration, not the mark: a mark cannot know the document's other authors, so a plugin walks the revisions, hands out `REVISION_AUTHOR_COLORS` (LibreOffice's own author palette) in order of first appearance and paints `pm-rev-ins`/`-del` + `pm-rev-a<n>`. That span nests *inside* the mark's, so it carries the underline/strikethrough too — drawn by the ancestor it would take the fallback colour. Cached on the doc; the walk is the whole document and `decorations` runs on every view update. `acceptRevision(id)`/`rejectRevision(id)` apply every range of one change, which is what `RevisionsPane.svelte` lists as one row.
- **`placeholderField.ts`** — a template placeholder, an inline atom drawn gray as ‹label›: a click selects the whole field (NodeSelection) and typing replaces it; Tab/Shift-Tab from a selected field jump to the next/previous one. Round-trips to ODF `<text:placeholder>` and a tagged DOCX `w:sdt` content control.
- **`caption.ts`** — a caption is an ordinary paragraph in the `Caption` style (LibreOffice's, probed: 10pt italic, 0.212cm above and below) holding one `sequenceField` atom, its running number. `sequenceFields(doc)` counts one counter per category in document order; the cached `number` attr is what an export writes, and an `appendTransaction` (off the undo stack) plus an `onCreate` pass keep it true — a file's own cached numbers are unreadable wherever the format is not arabic. The counter's name differs per format: ODF `Illustration`/`Table`, Word `Figure`/`Table`, and `ODF_SEQ_CATEGORY` reads both back. The caption paragraph is **boxed to the frame it captions** (`captionPlacement`), so it stands under the picture instead of at the margin: LibreOffice frames picture and caption together, and a loose paragraph carries that over as `indent`/`indentRight` — the frame's own span in the column, measured (`frameSpanCm`), since that span comes from the wrap, the offset and the column, not from one attr. The indent is also what lifts the caption clear of a **side-wrapped** frame: a line with no room beside a float drops below it. It is centred in that box only under a frame that is itself centred; a block with no frame at all has only its own alignment to pass on. `getFrameDebug` reports the same two spans for the debug dump. `CaptionDialog.svelte` (References tab) owns the insert.
- **`ruby.ts`** — a reading printed over its base (LibreOffice's Format ▸ Asian Phonetic Guide, Word's Phonetic Guide). An inline **atom** carrying both halves, because that is what both formats write: one element holding a base and a reading, edited as a unit (`RubyDialog.svelte`). Rendered as a real `<ruby>`, so the browser sets it. Alignment and position are both products' defaults and are not offered.
- **`bookmark.ts`** / **`crossReference.ts`** — a named range of text (a mark) and the live references to it (`REF`/`PAGEREF`). `ToolbarExpanded.svelte` owns both dialogs, as it does the link one.
@@ -0,0 +1,93 @@
import { Node, mergeAttributes, type Editor } from '@tiptap/core';
import { NodeSelection } from '@tiptap/pm/state';
// Inline atom for a template placeholder (LibreOffice's Insert ▸ Field ▸ Placeholder):
// gray ‹label› text; a click selects the whole field and typing replaces it.
// Round-trips to ODF <text:placeholder> and a tagged DOCX <w:sdt> content control.
export interface PlaceholderFieldAttrs {
/** The label shown inside ‹ ›, without the brackets. */
text: string;
}
export function placeholderFieldText(attrs: PlaceholderFieldAttrs): string {
return `‹${attrs.text}›`;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
placeholderField: {
insertPlaceholderField: (text: string) => ReturnType;
};
}
}
// Tab/Shift-Tab from a selected placeholder jump to the next/previous one, wrapping.
function selectAdjacentPlaceholder(editor: Editor, dir: 1 | -1): boolean {
const { state } = editor;
const sel = state.selection;
if (!(sel instanceof NodeSelection) || sel.node.type.name !== 'placeholderField') return false;
const positions: number[] = [];
state.doc.descendants((node, pos) => {
if (node.type.name === 'placeholderField') positions.push(pos);
});
const i = positions.indexOf(sel.from);
if (i < 0 || positions.length < 2) return true; // consume: Tab must not replace the field
const next = positions[(i + dir + positions.length) % positions.length];
editor.view.dispatch(state.tr.setSelection(NodeSelection.create(state.doc, next)).scrollIntoView());
return true;
}
export const PlaceholderField = Node.create({
name: 'placeholderField',
group: 'inline',
inline: true,
atom: true,
// Above Indent (1000) so Tab reaches this keymap first while a field is selected.
priority: 1100,
addAttributes() {
return {
text: {
default: '',
parseHTML: (el) => el.getAttribute('data-placeholder-field') || '',
renderHTML: (attrs) => ({ 'data-placeholder-field': attrs.text }),
},
};
},
parseHTML() {
return [{ tag: 'span[data-placeholder-field]' }];
},
renderHTML({ node, HTMLAttributes }) {
return ['span', mergeAttributes(HTMLAttributes), placeholderFieldText(node.attrs as PlaceholderFieldAttrs)];
},
renderText({ node }) {
return placeholderFieldText(node.attrs as PlaceholderFieldAttrs);
},
addKeyboardShortcuts() {
return {
Tab: () => selectAdjacentPlaceholder(this.editor, 1),
'Shift-Tab': () => selectAdjacentPlaceholder(this.editor, -1),
};
},
addCommands() {
return {
insertPlaceholderField: (text) => ({ commands, state }) => {
// Adopt the cursor's marks so the field renders like the surrounding text.
const marks = (state.storedMarks ?? state.selection.$to.marks())
.map((m) => ({ type: m.type.name, attrs: m.attrs }));
return commands.insertContent({
type: this.name,
attrs: { text },
...(marks.length ? { marks } : {}),
});
},
};
},
});
+2 -1
View File
@@ -70,4 +70,5 @@ U+E009 (columns), U+E00A (date/time fields), `STY` U+E00D (paragraph styles),
`TXBX_NUM` U+E01D (DOCX only: a numbering id a box's list has yet to be given),
`IXE` U+E01E (an alphabetical-index entry), `BIB` U+E01F (a citation),
`NOHYP` U+E020 (DOCX only: a paragraph whose w:pPr must gain w:suppressAutoHyphens),
`RBY` U+E021 (a ruby annotation).
`RBY` U+E021 (a ruby annotation), `PLH` U+E022 (a placeholder field — ODF
`<text:placeholder>`, DOCX a `w:sdt` content control tagged `edentext-placeholder`).
+42 -1
View File
@@ -118,6 +118,11 @@ const MTH = '';
// w:ruby, so applyRubyDocx swaps that run for one. U+E021, matching export/odt.ts.
const RBY = '\uE021';
// A placeholder field becomes a tagged <w:sdt> content control the package can't emit;
// applyPlaceholdersDocx swaps the sentinel run for one. U+E022, matching export/odt.ts.
const PLH = '\uE022';
export const PLACEHOLDER_SDT_TAG = 'edentext-placeholder';
// Formulas collected while serializing, in document order (module-level like
// docLangTag: inlineToRuns is reached from every block path without a collector).
type FormulaDocx = { latex: string; display: boolean };
@@ -126,6 +131,7 @@ let docFormulas: FormulaDocx[] = [];
// The ruby annotations, in the same document order their sentinels are met in.
type RubyDocx = { base: string; text: string };
let docRubies: RubyDocx[] = [];
let docPlaceholders: string[] = [];
// The sources cited, one per tag in document order — Word keeps them in a custom-XML
// part and the CITATION fields only name the tag. Module-level like docFormulas.
@@ -590,6 +596,10 @@ function inlineToRuns(content: TiptapNode[] = [], force: TextProps = {}): Inline
// The reading over its base text; applyRubyDocx swaps this run for <w:ruby>.
docRubies.push({ base: String(node.attrs?.base ?? ''), text: String(node.attrs?.text ?? '') });
out.push(new TextRun({ text: `${RBY}${docRubies.length - 1}${RBY}` }));
} else if (node.type === 'placeholderField') {
// applyPlaceholdersDocx swaps this run for a tagged <w:sdt> content control.
docPlaceholders.push(String(node.attrs?.text ?? ''));
out.push(new TextRun({ text: `${PLH}${docPlaceholders.length - 1}${PLH}`, ...runPropsFromMarks(node.marks) }));
} else if (node.type === 'indexEntry') {
// Word's index entry: a hidden XE field, its term in the instruction. A key files
// the term under it, "key:term", exactly as LibreOffice's text:key1 does.
@@ -996,6 +1006,9 @@ function txbxParagraphXml(node: TiptapNode, parts: TxbxParts, indentTwip = 0, nu
} else if (child.type === 'ruby') {
docRubies.push({ base: String(child.attrs?.base ?? ''), text: String(child.attrs?.text ?? '') });
runs += `<w:r><w:t xml:space="preserve">${RBY}${docRubies.length - 1}${RBY}</w:t></w:r>`;
} else if (child.type === 'placeholderField') {
docPlaceholders.push(String(child.attrs?.text ?? ''));
runs += `<w:r>${txbxRunPropsXml(child.marks)}<w:t xml:space="preserve">${PLH}${docPlaceholders.length - 1}${PLH}</w:t></w:r>`;
} else if (child.type === 'text' && child.text) {
const rPr = txbxRunPropsXml(child.marks);
// A recorded revision wraps the run; a deletion's text sits in w:delText.
@@ -1361,6 +1374,33 @@ function applyRubyDocx(bytes: Uint8Array, rubies: RubyDocx[]): Uint8Array {
return zipSync(out);
}
// Post-pack pass: swap each PLH sentinel run for a tagged inline <w:sdt> content
// control. w:temporary makes Word drop the control once the user types over it; the
// run keeps its own <w:rPr> and gains placeholder gray unless it has a color already.
function applyPlaceholdersDocx(bytes: Uint8Array, placeholders: string[]): Uint8Array {
if (!placeholders.length) return bytes;
const files = unzipSync(bytes);
const docBytes = files['word/document.xml'];
if (!docBytes) return bytes;
let xml = strFromU8(docBytes);
xml = xml.replace(
new RegExp(`<w:r\\b[^>]*?>(?:(?!</?w:r[\\s>])[\\s\\S])*?${PLH}(\\d+)${PLH}(?:(?!</?w:r[\\s>])[\\s\\S])*?</w:r>`, 'g'),
(m, idx: string) => {
const label = placeholders[Number(idx)];
if (label === undefined) return '';
let rpr = /<w:rPr>[\s\S]*?<\/w:rPr>/.exec(m)?.[0] ?? '<w:rPr></w:rPr>';
if (!rpr.includes('<w:color')) rpr = rpr.replace('<w:rPr>', '<w:rPr><w:color w:val="808080"/>');
return `<w:sdt><w:sdtPr><w:alias w:val="${escapeXml(label)}"/><w:tag w:val="${PLACEHOLDER_SDT_TAG}"/>`
+ '<w:temporary/><w:text/></w:sdtPr><w:sdtContent>'
+ `<w:r>${rpr}<w:t xml:space="preserve">${escapeXml(label)}</w:t></w:r></w:sdtContent></w:sdt>`;
},
);
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 }];
return zipSync(out);
}
const W14_NS = 'http://schemas.microsoft.com/office/word/2010/wordml';
const W15_NS = 'http://schemas.microsoft.com/office/word/2012/wordml';
@@ -2178,6 +2218,7 @@ export async function buildDocx(
exportSpacingModel = spacingModel;
docFormulas = [];
docRubies = [];
docPlaceholders = [];
docSources = [];
const num = new Numbering();
const { w: pageWidthCm, h: pageHeightCm } = pageDimsCm(pageFormat, orientation);
@@ -2341,7 +2382,7 @@ export async function buildDocx(
const blob = await Packer.toBlob(doc);
const packed = applyFormulasDocx(applyTextBoxesDocx(new Uint8Array(await blob.arrayBuffer()), textBoxes), docFormulas);
const cited = applyBibliographyDocx(applyRubyDocx(packed, docRubies), docSources, docCitationStyle(docJson));
const cited = applyBibliographyDocx(applyPlaceholdersDocx(applyRubyDocx(packed, docRubies), docPlaceholders), docSources, docCitationStyle(docJson));
const withNotes = docNoteIds.size ? applyNoteMarksDocx(applyNotePrDocx(cited, notesSettings)) : cited;
const withResolved = applyCommentsResolvedDocx(withNotes);
const mirrored = margins.mirrored ? applyMirrorMarginsDocx(withResolved) : withResolved;
+41 -2
View File
@@ -196,6 +196,10 @@ const BIB = '\uE01F';
// <text:ruby>. U+E021.
const RBY = '\uE021';
// Sentinel wrapping a placeholder field's index (PLH{i}PLH); applyPlaceholderFields
// rewrites it to <text:placeholder>. U+E022.
const PLH = '\uE022';
// Sentinels for recorded revisions (trackChanges.ts): TCI{i}TCI brackets an insertion's
// runs, TCD{i}TCD stands where a deletion's text was cut out. applyRevisions rewrites
// both and builds the <text:tracked-changes> registry. U+E01B/U+E01C.
@@ -574,6 +578,22 @@ function replaceDateTimeFields(node: TiptapNode, fields: DateTimeFieldExport[]):
return { ...node, content };
}
// Replace every inline `placeholderField` node with a PLH-sentinel text run and
// collect its label, mirroring replaceDateTimeFields.
function replacePlaceholderFields(node: TiptapNode, labels: string[]): TiptapNode {
if (!node.content?.length) return node;
const content: TiptapNode[] = [];
for (const child of node.content) {
if (child.type === 'placeholderField') {
labels.push(typeof child.attrs?.text === 'string' ? child.attrs.text : '');
content.push({ type: 'text', text: `${PLH}${labels.length - 1}${PLH}`, marks: child.marks });
continue;
}
content.push(replacePlaceholderFields(child, labels));
}
return { ...node, content };
}
// One recorded revision. Runs of the same change share an id, so the registry holds one
// entry per id and a deletion's text is the concatenation of its runs.
type RevisionExport = { kind: 'insertion' | 'deletion'; id: string; author: string; date: string; text: string; nodes: TiptapNode[] };
@@ -3957,6 +3977,23 @@ function applyDateTimeFields(odtBytes: Uint8Array, fields: DateTimeFieldExport[]
return rezipOdt(files);
}
// Rewrite each PLH sentinel to <text:placeholder>. The display text carries ASCII
// angle brackets, which is what LibreOffice writes for its own placeholder fields.
function applyPlaceholderFields(odtBytes: Uint8Array, labels: string[]): Uint8Array {
if (!labels.length) return odtBytes;
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
let content = strFromU8(contentBytes);
content = content.replace(new RegExp(`${PLH}(\\d+)${PLH}`, 'g'), (_m, idx: string) => {
const label = labels[Number(idx)];
if (label === undefined) return '';
return `<text:placeholder text:placeholder-type="text">${escapeXml(`<${label}>`)}</text:placeholder>`;
});
files['content.xml'] = strToU8(content);
return rezipOdt(files);
}
// Graphic style for a text box / shape: fill, stroke, text padding, auto-grow, and —
// for floating boxes — the same wrap/position props as floating images.
function textBoxGraphicStyle(box: TextBoxExport, index: number): string {
@@ -4474,6 +4511,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const textBoxes: TextBoxExport[] = [];
const columns: ColumnsExport[] = [];
const dateFields: DateTimeFieldExport[] = [];
const placeholderLabels: string[] = [];
const formulas: FormulaExport[] = [];
const crossRefs: CrossRefExport[] = [];
const notes: NoteExport[] = [];
@@ -4483,7 +4521,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const indexMarks: IndexEntryExport[] = [];
const bibMarks: BibExport[] = [];
const rubies: RubyExport[] = [];
const sentinels = replaceRuby(replaceBibEntries(replaceIndexEntries(replaceRevisions(replaceSequenceFields(replaceComments(replaceBookmarks(replaceFormulas(replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceSectionBreaks(replaceNotes(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns), notes)))), images), dateFields), formulas), crossRefs), commentList), seqFields), revisionList), indexMarks), bibMarks), rubies);
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);
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;
@@ -4688,7 +4726,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const withBreaks = applyInlineSentinels(applyTabLeaders(cleaned));
const withImages = applyImages(withBreaks, images);
const withDateFields = applyDateTimeFields(withImages, dateFields, language ?? null);
const withSequences = applyRuby(applyBibEntries(applyIndexEntries(applyRevisions(applySequenceFields(withDateFields, seqFields), revisionList, recordChanges), indexMarks), bibMarks), rubies);
const withPlaceholders = applyPlaceholderFields(withDateFields, placeholderLabels);
const withSequences = applyRuby(applyBibEntries(applyIndexEntries(applyRevisions(applySequenceFields(withPlaceholders, seqFields), revisionList, recordChanges), indexMarks), bibMarks), rubies);
const withFormulas = applyFormulas(withSequences, formulas);
const withTextBoxes = applyTextBoxes(withFormulas, textBoxes);
const withColumns = applyColumns(withTextBoxes, columns);
+1 -1
View File
@@ -27,7 +27,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
- **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`.
- **Word's horizontal line** (`hrBorderAttr`, DOCX): a `w:pict` > `v:rect` flagged `o:hr` is not a shape but the paragraph's own **bottom rule** — the editor has no rule node. Height and `fillcolor` become the rule (`1.5pt`/`#A0A0A0` where the file names none), its width and `o:hralign` are dropped (a paragraph rule spans the text width) and a real `w:pBdr` bottom keeps precedence: only one line can be drawn.
- **Content controls** (`inlineChildren`, DOCX): a `w:sdt` inside a paragraph — Word wraps every citation and bibliography entry in one — is walked through to its `w:sdtContent`, or the runs inside it never reach the paragraph (a fixture measured against LibreOffice lost every `[7]`-style reference and broke its lines one page short).
- **Content controls** (`inlineChildren`, DOCX): a `w:sdt` inside a paragraph — Word wraps every citation and bibliography entry in one — is walked through to its `w:sdtContent`, or the runs inside it never reach the paragraph (a fixture measured against LibreOffice lost every `[7]`-style reference and broke its lines one page short). The exception is one tagged `edentext-placeholder`: it survives whole as a `placeholderField` node, its export-minted gray stripped so the presentation color never becomes a mark. ODF's `text:placeholder` becomes the same node, the shown text's `<>`/`‹›` brackets stripped.
- **Formulas** (`convertFormulaFrame`): a `<draw:frame>` whose `<draw:object>` resolves to a sub-document with a `math:math` root (or holds one inline) → a `formula` node. Our own `<annotation encoding="application/x-tex">` is preferred; LibreOffice strips it, so the MathML is parsed back to LaTeX instead. **`display` comes from the paragraph** (`aloneInParagraph`), not the object — LibreOffice writes `display="block"` on every formula it re-saves. DOCX: `m:oMath`/`m:oMathPara` sit beside the `w:r` runs in their own namespace, so `convertInline` probes them ahead of its `namespaceURI !== W` guard (that guard silently dropped every Word formula). See `docs/architecture/formulas.md`.
- **Bookmarks & cross-references**: `text:bookmark-start`/`-end` (ODF) and `w:bookmarkStart`/`End` (DOCX) become a `bookmark` **mark** on the text they cover; the open ranges live in `Ctx` because Word writes them beside paragraphs as well as beside runs, and a range may close in a later paragraph. A **point** bookmark has no range to mark and is dropped, `_GoBack` with it. `text:bookmark-ref` and `REF`/`PAGEREF` fields (the `fldChar` machine and `w:fldSimple` alike, including a field Word left unresolved) become `crossRef` nodes carrying the producer's cached text. A `w:hyperlink w:anchor` with no `r:id` is an internal link → `#name`. See `docs/architecture/formatting.md`.
- **Page geometry is the *body's* master page**, not the one named `Standard`: the importer counts the blocks each `style:master-page-name` governs and re-reads geometry (and the app's own header/footer) from the winner. A document whose Standard master no paragraph references would otherwise take margins — and a footer band — no page of it has. **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.
+34 -1
View File
@@ -2,6 +2,7 @@ import { unzipSync, strFromU8 } from 'fflate';
import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, readTabStops, toggle as onOff, wVal, W, R, WP, A, B, WPS, MC, VML, O, PKG_REL, type RunProps, type ParaSpacing } from './docxStyles';
import { lengthToPt, WATERMARK_NAME } from './styleResolver';
import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, normalizeColor } from '../export/odt';
import { PLACEHOLDER_SDT_TAG } from '../export/docx';
import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { fitInlineImage, framePx } from '../editor/extensions/image';
@@ -1192,15 +1193,32 @@ function snapPt(v: number): number {
// ---- inline conversion (runs, marks, fields, images) -----------------------
// Word wraps a citation or any content control in a w:sdt holding ordinary inline
// content — walk through the wrapper, or those runs never reach the paragraph.
// content — walk through the wrapper, or those runs never reach the paragraph. Our
// own placeholder control is the exception: it survives whole and becomes a field.
function inlineChildren(el: Element): Element[] {
return Array.from(el.children).flatMap((c) => {
if (c.namespaceURI !== W || c.localName !== 'sdt') return [c];
if (sdtIsPlaceholder(c)) return [c];
const content = fc(c, 'sdtContent');
return content ? inlineChildren(content) : [];
});
}
function sdtIsPlaceholder(sdt: Element): boolean {
const pr = fc(sdt, 'sdtPr');
return !!pr && fc(pr, 'tag')?.getAttributeNS(W, 'val') === PLACEHOLDER_SDT_TAG;
}
// The export paints the control's run gray; that gray is presentation, not the
// author's formatting, so it must not come back as a color mark.
function stripPlaceholderGray(marks: Mark[]): Mark[] {
return marks.flatMap((m) => {
if (m.type !== 'textStyle' || String(m.attrs?.color ?? '').toLowerCase() !== '#808080') return [m];
const { color: _color, ...rest } = m.attrs ?? {};
return Object.keys(rest).length ? [{ type: m.type, attrs: rest }] : [];
});
}
// A w:footnoteReference → the anchor node, with the note's own paragraphs collected
// into ctx for the section the document end gets. Word's numbering is implicit, so the
// citation is counted here; several paragraphs flatten to hard breaks.
@@ -1465,6 +1483,21 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
if (trackBookmark(el, ctx) || trackComment(el, ctx)) continue;
switch (el.localName) {
case 'r': handleRun(el); break;
case 'sdt': {
// Only a placeholder-tagged control gets here (inlineChildren unwraps the
// rest): a field in the body, its plain runs in a header/footer zone.
const content = fc(el, 'sdtContent');
if (hfFields) { if (content) for (const r of fcAll(content, 'r')) handleRun(r); break; }
const pr = fc(el, 'sdtPr');
const alias = pr ? fc(pr, 'alias')?.getAttributeNS(W, 'val') : null;
const shown = (content?.textContent ?? '').trim().replace(/^[<‹]/, '').replace(/[>›]$/, '');
const field: Node = { type: 'placeholderField', attrs: { text: (alias || shown).trim() } };
const first = content ? fcAll(content, 'r')[0] : undefined;
const m = first ? stripPlaceholderGray(runMarks(first)) : [];
if (m.length) field.marks = m;
out.push(field);
break;
}
case 'hyperlink': {
const rid = el.getAttributeNS(R, 'id');
// No relationship id: an internal link to a bookmark in this document.
+11
View File
@@ -2160,6 +2160,17 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl
continue;
}
}
// A placeholder field (Insert ▸ Field ▸ Placeholder): the label is the
// shown text without the <>/‹› brackets either producer draws around it.
if (!hfFields && e.localName === 'placeholder') {
const label = (e.textContent ?? '').trim().replace(/^[<‹]/, '').replace(/[>›]$/, '');
const field: Node = { type: 'placeholderField', attrs: { text: label } };
const marks = marksFor(props, ctx.resolver, defaults);
if (linkHref) marks.push({ type: 'link', attrs: { href: linkHref } });
if (marks.length) field.marks = marks;
out.push(field);
continue;
}
// A caption's running number (caption.ts). Only the two categories the
// editor counts survive as a live field; any other sequence (a user
// variable, a chapter counter) keeps its evaluated text.
+10
View File
@@ -885,6 +885,16 @@
height: 0;
}
/* A template placeholder field: gray until replaced, as LibreOffice draws its own.
The sheet keeps its own colors through every theme, so the gray is fixed too. */
.paper .tiptap span[data-placeholder-field] {
color: #808080;
}
.paper .tiptap span[data-placeholder-field].ProseMirror-selectednode {
background: color-mix(in srgb, var(--color-primary) 25%, transparent);
outline: none;
}
/* Paragraph mark (). Out of flow at its static position — after the last character,
but invisible to line breaking, so a line-end space still hangs into the margin and
the mark can never be pushed onto a line of its own. The toggle only recolors it. */
+4
View File
@@ -182,6 +182,10 @@ function paragraph(r: Rng, indents = true, top = false): N {
fixed: false, value: '2026-08-16T10:30:00' } });
}
if (maybe(r, 0.04)) body.push({ type: 'ruby', attrs: { base: '漢字', text: 'かんじ' } });
// label without leading/trailing space: both importers trim the shown text.
if (maybe(r, 0.04)) {
body.push({ type: 'placeholderField', attrs: { text: pick(r, ['Empfängername', 'Straße & <Nr>', 'Datum "heute"']) } });
}
// display=false only: ODF has no display flag, the importer derives it from the formula
// owning its line (aloneInParagraph) — a display formula is its own paragraph (genDoc).
if (maybe(r, 0.05)) {
+47
View File
@@ -2488,3 +2488,50 @@ describe('Leg 30: a figure frame (picture + caption in one box)', () => {
check('box comes back whole', shapeOf(back) === 'image|text+sequenceField+text', shapeOf(back));
});
});
describe('Leg 31: placeholder fields (ODT + DOCX)', () => {
const PLF = (text: string, marks?: N[]): N =>
({ type: 'placeholderField', attrs: { text }, ...(marks ? { marks } : {}) });
const BOLD = { type: 'bold' };
const doc: N = {
type: 'doc',
content: [
P(null, T('Sehr geehrte '), PLF('Empfängername'), T(',')),
P(null, PLF('Betreff & <Zeichen>', [BOLD])),
],
};
const fieldsOf = (res: N): N[] => {
const out: N[] = [];
(function walk(n: N) { if (n.type === 'placeholderField') out.push(n); for (const c of n.content ?? []) walk(c); })(res.content);
return out;
};
it('ODT: exports <text:placeholder> and re-imports label + marks', async () => {
const bytes = await buildOdt(doc, margins);
const content = strFromU8(unzipSync(bytes)['content.xml']);
check('emits text:placeholder', content.includes('<text:placeholder text:placeholder-type="text">'), content.slice(0, 200));
check('label carries LibreOffice-style brackets', content.includes('&lt;Empfängername&gt;'));
check('no leftover sentinel', !content.includes(''));
const fields = fieldsOf(importOdt(bytes));
check('both fields imported', fields.length === 2, fields);
check('label round-trips', fields[0]?.attrs?.text === 'Empfängername', fields[0]?.attrs);
check('special characters survive', fields[1]?.attrs?.text === 'Betreff & <Zeichen>', fields[1]?.attrs);
check('bold mark survives', (fields[1]?.marks ?? []).some((m: N) => m.type === 'bold'), fields[1]?.marks);
});
it('DOCX: exports a tagged <w:sdt> and re-imports it as a field', async () => {
const bytes = await buildDocx(doc, margins);
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
check('emits a content control', xml.includes('<w:sdt>'), xml.slice(0, 200));
check('tagged as ours', xml.includes('w:tag w:val="edentext-placeholder"'));
check('gray placeholder color', xml.includes('<w:color w:val="808080"/>'));
check('no leftover sentinel', !xml.includes(''));
const fields = fieldsOf(importDocx(bytes));
check('both fields imported', fields.length === 2, fields);
check('label round-trips', fields[0]?.attrs?.text === 'Empfängername', fields[0]?.attrs);
check('special characters survive', fields[1]?.attrs?.text === 'Betreff & <Zeichen>', fields[1]?.attrs);
check('bold mark survives', (fields[1]?.marks ?? []).some((m: N) => m.type === 'bold'), fields[1]?.marks);
check('the gray does not come back as a color mark',
!fields.some((f) => (f.marks ?? []).some((m: N) => m.type === 'textStyle' && m.attrs?.color)), fields);
});
});