feat: a reading can be printed over its base text

Ruby, as both products' phonetic guide writes it: one element holding a
base and its reading, so the editor keeps it as one inline atom and the
browser sets it as a real <ruby>.

ODF gets <text:ruby> plus a minted ruby-family style (centred, above —
probed: LibreOffice writes that style back unchanged); Word a <w:ruby> run
from a post-pack pass, the package having none. Both legs verified through
a LibreOffice re-save, our .docx to .odt included.
This commit is contained in:
Steffen Becker committed 2026-08-15 10:40:52 +02:00
1 parent 405b175e49
commit 20e6dcad0e
17 files changed
+405 -6

No files matched your search

+2 -1
View File
@@ -58,6 +58,7 @@
- AutoCorrect while typing (LibreOffice's Tools ▸ AutoCorrect Options, one checkbox per rule): typographic quotes per document language (`"…"` / `„…“`), its dash matrix (`A - B` → `A – B`, `A--B` → `A—B`), its replacement table (`-->` → `→`, `(C)` → `©`, `...` → `…`), capitalize the first letter of a sentence — with an abbreviation exception list — and TWo INitial CApitals. URL recognition and auto-list ride the same switches
- Word completion (the same dialog's own section, as LibreOffice keeps it): every word of at least eight letters is remembered as it is typed, and typing its first three letters offers the rest in grey after the caret — Enter takes the offer, Esc drops it. It is offered while typing only, so an Enter meant to split a paragraph never completes a word instead, and the offer stays outside the document until it is accepted. Minimum length, an appended space and clearing the collected words are all settable
- AutoText (LibreOffice's Tools ▸ AutoText, Word's Insert ▸ Quick Parts): keep a selected block of text under a name and a shortcut, then insert it from the library or by typing the shortcut and pressing F3 — LibreOffice's own key. An entry keeps its formatting, and the library belongs to the app rather than to a document, as it does in both products
- Ruby annotations (Insert ▸ Phonetic guide — LibreOffice's Format ▸ Asian Phonetic Guide, Word's Phonetic Guide): the reading printed over its base text, the selected text proposed as the base. Round-trips as ODF `text:ruby` with its ruby-family style and as Word's `w:ruby` run — verified through LibreOffice both ways, including our `.docx` converted to `.odt` by it
- Automatic hyphenation for the whole document (Layout ▸ Hyphenation): the browser hyphenates in the document's own language, which shortens a justified paragraph the way LibreOffice does. Round-trips as ODF `fo:hyphenate` on the base style — where LibreOffice keeps it, in its *text* properties — and Word's `w:autoHyphenation`
- Manual line breaks (Shift+Enter)
@@ -163,7 +164,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-14.
- Find & Replace by font, size or colour — the weight/slant/underline and paragraph-style half is implemented, but LibreOffice's Format dialog is the whole character dialog
- Multi-document management: one document is open at a time, so there is no window list and no side-by-side compare
- Thesaurus and grammar check
- Vertical writing modes (ODF `tb-rl`) and Asian typography (ruby)
- Vertical writing modes (ODF `tb-rl`), and a ruby annotation's own alignment and position — both products' defaults are what we write
- Password-protected ODT/DOCX; digital signatures
- Split view: two views of one document, scrolled independently
- A committed corpus of documents we author ourselves, so CI can test against real files
+134
View File
@@ -0,0 +1,134 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { t } from '../i18n/i18n.svelte';
// LibreOffice's Format ▸ Asian Phonetic Guide / Word's Phonetic Guide: the base text
// and the reading printed over it. Alignment and position follow both defaults
// (centred, above), which is what the exports write.
let { open = $bindable(false), editor }: { open?: boolean; editor: Editor | null } = $props();
let dialogEl = $state<HTMLDialogElement | null>(null);
let base = $state('');
let reading = $state('');
$effect(() => {
const el = dialogEl;
if (!el) return;
if (open && !el.open) el.showModal();
else if (!open && el.open) el.close();
});
// The selected text is the base, as it is in both dialogs.
$effect(() => {
if (!open || !editor) return;
const { from, to } = editor.state.selection;
base = to > from ? editor.state.doc.textBetween(from, to, ' ') : '';
reading = '';
});
function insert() {
if (!base.trim()) return;
editor?.chain().focus().insertRuby({ base, text: reading }).run();
open = false;
}
</script>
<dialog
bind:this={dialogEl}
onclose={() => (open = false)}
onclick={(e) => e.target === dialogEl && (open = false)}
aria-label={t().ruby.title}
>
<div class="body">
<h2>{t().ruby.title}</h2>
<label class="row">
<span>{t().ruby.base}</span>
<input bind:value={base} />
</label>
<label class="row">
<span>{t().ruby.reading}</span>
<input bind:value={reading} onkeydown={(e) => e.key === 'Enter' && insert()} />
</label>
{#if base.trim()}
<p class="preview"><ruby>{base}<rt>{reading}</rt></ruby></p>
{/if}
<div class="actions">
<span class="spacer"></span>
<button onclick={() => (open = false)}>{t().common.cancel}</button>
<button class="primary" disabled={!base.trim()} onclick={insert}>{t().common.insert}</button>
</div>
</div>
</dialog>
<style>
dialog {
/* The global reset zeroes every margin, which also takes the auto centring a
modal <dialog> gets by default. */
margin: auto;
border: none;
border-radius: 8px;
padding: 0;
background: var(--color-surface);
color: var(--color-text);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
dialog::backdrop { background: rgba(0, 0, 0, 0.35); }
.body {
display: flex;
flex-direction: column;
gap: 10px;
width: 320px;
padding: 18px 20px 16px;
font-family: var(--font-sans);
font-size: 0.85rem;
}
h2 { font-size: 1rem; }
.row { display: flex; align-items: center; gap: 8px; }
.row span { flex: 0 0 5.5rem; }
input {
flex: 1;
min-width: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
padding: 4px 6px;
font: inherit;
}
.preview {
padding: 6px 0;
font-family: var(--font-serif, serif);
font-size: 1.2rem;
text-align: center;
}
.preview rt { font-size: 0.5em; }
.actions { display: flex; align-items: center; gap: 8px; padding-top: 4px; }
.spacer { flex: 1; }
.actions button {
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
padding: 5px 14px;
font: inherit;
cursor: pointer;
}
.actions button:hover:not(:disabled) { background: var(--color-btn-hover); }
.actions button:disabled { opacity: 0.5; cursor: default; }
.actions .primary {
border-color: var(--color-primary);
background: var(--color-primary);
color: #fff;
}
</style>
+2
View File
@@ -137,6 +137,8 @@ export const STROKED = {
footer: ['M3.5 1.75h9v12.5h-9z', 'M5.4 4h5.2M5.4 6.1h5.2M5.4 8.2h3.1'],
pageNumber: ['M3.25 1.75h9.5v12.5h-9.5z', 'M6.9 6.4 8 5.3v5.2', 'M6.75 10.5h2.5'],
pageBreak: ['M2 2.5h12M2 13.5h12', 'M8 5.5v5.2', 'M6.2 8.9 8 10.7l1.8-1.8'],
// A big glyph with its small reading over it: what the annotation prints.
ruby: ['M4.5 13.2V6.6M4.5 6.6h4.2M8.7 6.6v6.6', 'M11 4.6h3.2M12.6 4.6v-2.4'],
// A page with one saved block picked out of its text: the stored entry.
quickParts: ['M3 1.75h10v12.5H3z', 'M5.2 4.4h5.6v3H5.2z', 'M5.2 9.9h5.6M5.2 12h3.4'],
} as const;
@@ -9,6 +9,7 @@
import BookmarkDialog from '../../BookmarkDialog.svelte';
import CrossRefDialog from '../../CrossRefDialog.svelte';
import FormulaDialog from '../../FormulaDialog.svelte';
import RubyDialog from '../../RubyDialog.svelte';
import { captionClicks, anchored, clickOutside, isMenuOpen, toggleMenu, closeMenu } from '../menu.svelte';
import { OPEN_LINK_DIALOG_EVENT } from '../../../editor/extensions/link';
import { OPEN_BOOKMARK_DIALOG_EVENT, bookmarkNames, findBookmark } from '../../../editor/extensions/bookmark';
@@ -37,6 +38,8 @@
onAutoText?: () => void;
} = $props();
let rubyOpen = $state(false);
let hasSelection = $derived(tick >= 0 && !!editor && !editor.state.selection.empty);
let isLink = $derived(tick >= 0 && !!editor?.isActive('link'));
let bmNames = $derived(tick >= 0 && editor && !hfActive ? bookmarkNames(editor.state.doc) : []);
@@ -264,6 +267,14 @@
disabled={!editor || !onAutoText}
onclick={() => onAutoText?.()}
/>
<RibbonButton
variant="big"
icon="ruby"
label={t().ruby.title}
title={t().ruby.title}
disabled={!editor}
onclick={() => (rubyOpen = true)}
/>
<div class="rb-captioned" use:captionClicks>
<DateTimePicker
bind:open={dateOpen}
@@ -296,6 +307,7 @@
onchange={onImageFile}
/>
<RubyDialog bind:open={rubyOpen} {editor} />
<FormulaDialog bind:open={formulaOpen} initialLatex={formulaLatex} initialDisplay={formulaDisplay} onApply={applyFormula} />
<!-- Word's Equation button is a π, as its Symbol button beside it is an Ω. Same
+3
View File
@@ -57,6 +57,7 @@ import { SequenceField } from './extensions/caption';
import { IndexEntry } from './extensions/indexEntry';
import { BibliographyEntry } from './extensions/bibliographyEntry';
import { CrossReference } from './extensions/crossReference';
import { Ruby } from './extensions/ruby';
import { Formula } from './extensions/formula';
import { TextBox } from './extensions/textBox';
import { Columns } from './extensions/columns';
@@ -126,6 +127,8 @@ export const extensions = [
// Inline reference to a bookmark, showing its text or its page; kept live by its
// node view. Round-trips to ODF <text:bookmark-ref> and DOCX REF/PAGEREF fields.
CrossReference,
// A reading printed over its base text; round-trips to ODF <text:ruby> and DOCX w:ruby.
Ruby,
// Mathematical formula; stores LaTeX, renders native MathML, round-trips to an ODF
// embedded formula object and DOCX OMML. See docs/architecture/formulas.md.
Formula,
+1
View File
@@ -32,6 +32,7 @@ Five topics are large enough to have their own deep-dive — read the file befor
- **`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). 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. `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.
- **`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.
- **`searchReplace.ts`** — Find & Replace: decorations highlight all matches, commands navigate/replace. Touches the document only for actual replacements. `useRegex` takes the term as a pattern instead of escaping it (a whole-word search then wraps it in a non-capturing group first, so the `\b` guards bound the alternation); each match keeps its captures so `expandGroups` can fill `$1`…`$9`/`$&` in the replacement. A `FormatSpec` (weight/slant/underline and a paragraph style, LibreOffice's Format… and its Paragraph Styles box) narrows a text search to the runs carrying it, and **with no term is the search**: every such run, or every paragraph in the style, empty ones included. The same spec on the replacement applies it — and a replacement with **no text** keeps the text and only reformats, which is how both dialogs restyle a document without retyping it. A style also switches the node type, as `setParagraphStyle` does, so the extension takes the same `sheet` option.
- **`spellCheck.ts`** — decorations from `spell/controller.ts`; `spellErrorAt` feeds the context menu.
+70
View File
@@ -0,0 +1,70 @@
import { Node, mergeAttributes } from '@tiptap/core';
// A ruby annotation: the small reading printed over a run of text (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 its reading, edited as a unit.
declare module '@tiptap/core' {
interface Commands<ReturnType> {
ruby: {
insertRuby: (attrs: { base: string; text: string }) => ReturnType;
};
}
}
export const Ruby = Node.create({
name: 'ruby',
group: 'inline',
inline: true,
atom: true,
addAttributes() {
return {
base: {
default: '',
parseHTML: (el) => baseTextOf(el as HTMLElement),
renderHTML: () => ({}),
},
text: {
default: '',
parseHTML: (el) => (el as HTMLElement).querySelector('rt')?.textContent?.trim() ?? '',
renderHTML: (attrs) => ({ 'data-ruby': String(attrs.text ?? '') }),
},
};
},
parseHTML() {
return [{ tag: 'ruby' }];
},
renderHTML({ HTMLAttributes, node }) {
return [
'ruby',
mergeAttributes(HTMLAttributes, { class: 'pm-ruby' }),
String(node.attrs.base ?? ''),
['rt', {}, String(node.attrs.text ?? '')],
];
},
addCommands() {
return {
insertRuby: (attrs) => ({ commands }) => {
const base = attrs.base.trim();
if (!base) return false;
return commands.insertContent({ type: this.name, attrs: { base, text: attrs.text.trim() } });
},
};
},
});
// The base is everything the element holds outside its <rt> (and <rp>, the brackets a
// reader without ruby support falls back to).
function baseTextOf(el: HTMLElement): string {
let out = '';
for (const child of Array.from(el.childNodes)) {
const tag = child.nodeType === 1 ? (child as HTMLElement).tagName.toLowerCase() : '';
if (tag !== 'rt' && tag !== 'rp') out += child.textContent ?? '';
}
return out.trim();
}
+3 -1
View File
@@ -36,6 +36,7 @@ Frames, tables and pagination have their own deep-dives in `docs/architecture/`.
- **Captions** (`replaceSequenceFields`/`applySequenceFields`): a `SEQ` sentinel becomes a `<text:sequence>` carrying LibreOffice's own `text:formula="ooow:<name>+1"` (what makes its reader recount on load) plus a `<text:sequence-decl>` per counter used; DOCX gets a `SimpleField` `SEQ Figure \* ARABIC`. Both keep the editor's resolved rank as the field's cached result.
- **Tracked changes** (`replaceRevisions`/`applyRevisions`): ODF splits the pair. An insertion keeps its text inline and is bracketed by `TCI` sentinels → `<text:change-start/>`/`<text:change-end/>`; a deletion's text is **cut out of the run** and left as one `TCD` → `<text:change/>`, the text itself going into the `<text:tracked-changes>` registry `applyRevisions` builds right after `<office:text>` (a `<text:deletion>` holds the deleted paragraphs, a `<text:insertion>` only its `<office:change-info>`). The registry's `<dc:creator>`/`<dc:date>` need `ensureDcNamespace` — odf-kit declares no `dc:` prefix in content.xml. DOCX needs no string pass: `InsertedTextRun`/`DeletedTextRun` from the `docx` package emit `w:ins`/`w:del` + `w:delText`, with `docRevisionId` numbering them per document as it does comments.
- **Bibliography** (`replaceBibEntries`/`applyBibEntries`, `applyBibliographyDocx`): a `BIB` sentinel becomes a `<text:bibliography-mark>` whose attributes are the whole source record and which **wraps** the text the citation shows (unlike an index mark, which is a point). The `<text:bibliography>` index writes an entry template per type actually cited, spelling out the same "key: author, title, year" the node view prints — LibreOffice regenerates the rows from it, so a template that said anything else would rewrite them. DOCX cites by tag (`CITATION "key"`) and keeps the sources in a **custom-XML part** the `docx` package knows nothing about, so the post-pack pass mints all four pieces itself: `customXml/item1.xml` (`b:Sources`), its `itemProps1.xml`, both relationships and the properties part's content type. (Probed: LibreOffice's own DOCX export writes the `CITATION` field but no sources at all.)
- **Ruby** (`replaceRuby`/`applyRuby`, `applyRubyDocx`): an `RBY` sentinel becomes a `<text:ruby>` holding both halves plus one minted `style:family="ruby"` automatic style (centred, above — probed: LibreOffice writes that style straight back out, adding only its own `loext:` mirror of the position). Word's is a `<w:ruby>` run the `docx` package cannot emit, so a post-pack pass swaps the sentinel run for it; the `w:rubyPr` sizes are half-points of the 12pt body default, which is what LibreOffice's own export writes.
- **Cell formulas** (`applyCellFormulas`, `formulaCellContent`): ODF puts the formula on the **cell**, so the pass walks `<table:table-cell` in the same document order `applyCellBlocks` does and writes `table:formula="ooow:…"` + the cached `office:value`. The `ooow:` is a namespace prefix, so `ensureOoowNamespace` declares it — undeclared, LibreOffice reads it as part of the formula. Word instead keeps a field *inside* the cell, so the DOCX side swaps the cell's first paragraph for one `SimpleField`. See `docs/architecture/tables.md`.
- **`applyListItemStyles`** — per-item alignment/spacing/line-height for top-level lists (odf-kit's ListBuilder has no per-item options) via minted automatic styles.
- **`applyListIndents`** — a top-level list's whole-list `indent` (cm) added to its `L#` list-style per-level `fo:margin-left`/`text:list-tab-stop-position` (label-alignment mode ignores the paragraph margin, so the shift must live in the list style). Import reverses it in `convertList` (level-1 margin minus the 1.27cm base).
@@ -63,4 +64,5 @@ U+E009 (columns), U+E00A (date/time fields), `STY` U+E00D (paragraph styles),
`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),
`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).
`NOHYP` U+E020 (DOCX only: a paragraph whose w:pPr must gain w:suppressAutoHyphens),
`RBY` U+E021 (a ruby annotation).
+42 -1
View File
@@ -113,11 +113,19 @@ const EMU_PER_CM = 360000;
// matching export/odt.ts's MTH.
const MTH = '';
// Sentinel wrapping a ruby annotation's index inside its own run; the library has no
// w:ruby, so applyRubyDocx swaps that run for one. U+E021, matching export/odt.ts.
const RBY = '\uE021';
// 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 };
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[] = [];
// 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.
let docSources: BibSource[] = [];
@@ -533,6 +541,10 @@ function inlineToRuns(content: TiptapNode[] = [], force: TextProps = {}): Inline
out.push(dateTimeRun(node));
} else if (node.type === 'sequenceField') {
out.push(sequenceField(node));
} else if (node.type === 'ruby') {
// 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 === '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.
@@ -1109,6 +1121,34 @@ function applyFormulasDocx(bytes: Uint8Array, formulas: FormulaDocx[]): Uint8Arr
return zipSync(out);
}
// Post-pack pass: swap each sentinel run for a <w:ruby>. The sizes are half-points of
// the body default (12pt): Word wants the base size and the reading's, and recomputes
// neither — LibreOffice's own export writes the same four properties.
function applyRubyDocx(bytes: Uint8Array, rubies: RubyDocx[]): Uint8Array {
if (!rubies.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])*?${RBY}(\\d+)${RBY}(?:(?!</w:r>)[\\s\\S])*?</w:r>`, 'g'),
(_m, idx: string) => {
const r = rubies[Number(idx)];
if (!r) return '';
const run = (text: string, size = 0) =>
`<w:r>${size ? `<w:rPr><w:sz w:val="${size}"/><w:szCs w:val="${size}"/></w:rPr>` : ''}`
+ `<w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r>`;
return '<w:r><w:ruby><w:rubyPr><w:rubyAlign w:val="center"/><w:hps w:val="12"/>'
+ `<w:hpsRaise w:val="24"/><w:hpsBaseText w:val="24"/><w:lid w:val="${docLangTag}"/></w:rubyPr>`
+ `<w:rt>${run(r.text, 12)}</w:rt><w:rubyBase>${run(r.base)}</w:rubyBase></w:ruby></w:r>`;
},
);
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);
}
// 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.
@@ -1853,6 +1893,7 @@ export async function buildDocx(
exportSheet = styles;
exportSpacingModel = spacingModel;
docFormulas = [];
docRubies = [];
docSources = [];
const num = new Numbering();
const { w: pageWidthCm, h: pageHeightCm } = pageDimsCm(pageFormat, orientation);
@@ -2011,7 +2052,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(packed, docSources, docCitationStyle(docJson));
const cited = applyBibliographyDocx(applyRubyDocx(packed, docRubies), docSources, docCitationStyle(docJson));
const withNotes = docNoteIds.size ? applyNotePrDocx(cited, notesSettings) : cited;
const mirrored = margins.mirrored ? applyMirrorMarginsDocx(withNotes) : withNotes;
const bidi = applyNoHyphensDocx(rtl ? applyBidiDocx(mirrored) : mirrored);
+50 -2
View File
@@ -191,6 +191,9 @@ const SEQ = '\uE01A';
const IXE = '\uE01E';
// Sentinel wrapping a citation's index (BIB{i}BIB); applyBibEntries resolves it.
const BIB = '\uE01F';
// Sentinel wrapping a ruby annotation's index (RBY{i}RBY); applyRuby rewrites it to
// <text:ruby>. U+E021.
const RBY = '\uE021';
// 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
@@ -795,6 +798,50 @@ function applyBibEntries(odtBytes: Uint8Array, marks: BibExport[]): Uint8Array {
return rezipOdt(files);
}
// One ruby annotation, collected by replaceRuby: the base text and the reading over it.
type RubyExport = { base: string; text: string };
function replaceRuby(node: TiptapNode, rubies: RubyExport[]): TiptapNode {
if (!node.content?.length) return node;
const content: TiptapNode[] = [];
for (const child of node.content) {
if (child.type === 'ruby') {
const a = child.attrs ?? {};
rubies.push({ base: String(a.base ?? ''), text: String(a.text ?? '') });
content.push({ type: 'text', text: `${RBY}${rubies.length - 1}${RBY}`, marks: child.marks });
continue;
}
content.push(replaceRuby(child, rubies));
}
return { ...node, content };
}
// RBY sentinels → <text:ruby>. The alignment and the position ride a ruby-family
// automatic style, which is where LibreOffice keeps them (probed: it writes the style
// out again unchanged, adding only its own loext: mirror of the position).
function applyRuby(odtBytes: Uint8Array, rubies: RubyExport[]): Uint8Array {
if (!rubies.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(`${RBY}(\\d+)${RBY}`, 'g'), (_m, idx: string) => {
const r = rubies[Number(idx)];
if (!r) return '';
return `<text:ruby text:style-name="${RUBY_STYLE}"><text:ruby-base>${escapeXml(r.base)}</text:ruby-base>`
+ `<text:ruby-text>${escapeXml(r.text)}</text:ruby-text></text:ruby>`;
});
content = injectAutomaticStyles(
content,
`<style:style style:name="${RUBY_STYLE}" style:family="ruby">`
+ '<style:ruby-properties style:ruby-align="center" style:ruby-position="above"/></style:style>',
);
files['content.xml'] = strToU8(content);
return rezipOdt(files);
}
const RUBY_STYLE = 'Ru1';
// One cross-reference, collected by replaceCrossRefs and emitted by applyBookmarks.
type CrossRefExport = { name: string; format: 'text' | 'page' };
@@ -4249,7 +4296,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const revisionList = new Map<string, RevisionExport>();
const indexMarks: IndexEntryExport[] = [];
const bibMarks: BibExport[] = [];
const sentinels = 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);
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 raw = markTextEffects(bakeListCharStyles(sentinels, 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;
@@ -4453,7 +4501,7 @@ 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 = applyBibEntries(applyIndexEntries(applyRevisions(applySequenceFields(withDateFields, seqFields), revisionList), indexMarks), bibMarks);
const withSequences = applyRuby(applyBibEntries(applyIndexEntries(applyRevisions(applySequenceFields(withDateFields, seqFields), revisionList), indexMarks), bibMarks), rubies);
const withFormulas = applyFormulas(withSequences, formulas);
const withTextBoxes = applyTextBoxes(withFormulas, textBoxes);
const withColumns = applyColumns(withTextBoxes, columns);
+5
View File
@@ -40,6 +40,11 @@ const de: Messages = {
urls: '',
},
},
ruby: {
title: 'Phonetische Zeichen',
base: 'Basistext',
reading: 'Ruby-Text',
},
autoText: {
title: 'AutoText',
empty: 'Noch keine Einträge. Text markieren und unten hinzufügen.',
+5
View File
@@ -41,6 +41,11 @@ const en = {
urls: '',
},
},
ruby: {
title: 'Phonetic guide',
base: 'Base text',
reading: 'Ruby text',
},
autoText: {
title: 'AutoText',
empty: 'No entries yet. Select some text and add it below.',
+2 -1
View File
@@ -24,13 +24,14 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
- **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`.
- **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 (the thesis fixture 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).
- **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.
- **A page-anchored frame** (`text:anchor-type="page"`) is out of the flow: it rides a paragraph of its own that collapses to nothing, and the node view places it from its page's corner (`anchorPage`, `image.ts`) — that paragraph must stay `position: static` (`editor.css`, `!important` over the line-height rule's own `position: relative`), or it becomes the frame's containing block instead of the page and offsets it by a page margin. Behind the text by default (`style:run-through="background"`); a cover page's own graphic instead declares `"foreground"` (`inFront` attr) to sit in front and hide what the page hoists behind it (a redundant logo, a title paragraph the graphic already carries). In a cell it falls back to an ordinary floating frame.
- **A header/footer zone is fitted to its schema** (`fitZoneSchema`): the zone editor has fewer marks and nodes than the body, and one it doesn't know makes `generateHTML` throw — which renders the whole zone blank. Unknown marks are dropped, unknown nodes leave their text.
- **A hoisted text box keeps what its anchor paragraph gave it**: an as-char figure frame is centred by the paragraph it sits in, so `wrapAlign` carries that (or the frame's own `style:horizontal-pos`) onto the box. Its `fo:padding` is read too — the editor's own 0.15cm ring is only the default.
- **Ruby**: `text:ruby` and `w:ruby` are one atom here (`ruby.ts`), so only the two texts are read — the base out of `text:ruby-base` / `w:rubyBase`, the reading out of `text:ruby-text` / `w:rt`. A file's own alignment and position are dropped; both products' defaults are what the exports write.
- **Bibliography**: a `text:bibliography-mark`'s every `text:` attribute but the identifier and the type is a source field, whatever the file names it, so nothing is lost on a format we have no UI for. Word's `CITATION` names a tag only — the record comes from `customXml/item*.xml` (each is probed for a `b:Sources` root), and a tag no source declares still cites with the field's cached text. `BIBLIOGRAPHY` and `text:bibliography` both become the index node.
- **Tracked changes**: ODF points at a registry, so `odfRevisions(body)` reads `<text:tracked-changes>` first and the body walk resolves each `text:change-start`/`-end` and `text:change` against it (the open insertions live in `Ctx` — a range may close in a later paragraph). A `text:change` is a deletion whose text is *only* in the registry: it is emitted there and then, marked. Word needs no registry — `w:ins`/`w:del` wrap their runs, whose text sits in `w:delText` rather than `w:t` for a deletion.
- **Footnotes and endnotes** round-trip: `text:note` (ODF) and `word/footnotes.xml`/`endnotes.xml` (DOCX) become a `noteRef` anchor plus a `note` in the document-final `noteSection`, and the numbering configuration becomes `NoteSettings`. See `docs/architecture/notes.md`.
+8
View File
@@ -1320,6 +1320,14 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
if ((fieldShown || fieldSeq) && fieldMode === 'result') fieldResultText += child.textContent ?? '';
else if (!skipResult()) pushText(child.textContent ?? '', marks);
break;
case 'ruby': {
// Word nests both halves in runs of their own; the editor keeps them as one
// atom, so only their text is read.
const base = fcAll(child, 'rubyBase')[0]?.textContent ?? '';
const reading = fcAll(child, 'rt')[0]?.textContent ?? '';
if (base.trim() && !skipResult()) out.push({ type: 'ruby', attrs: { base: base.trim(), text: reading.trim() } });
break;
}
case 'tab': if (!skipResult()) pushText('\t', marks); break;
case 'br': out.push(child.getAttributeNS(W, 'type') === 'page' ? { type: PB_MARKER } : hardBreakNode(marks)); break;
case 'cr': out.push(hardBreakNode(marks)); break;
+7
View File
@@ -1864,6 +1864,13 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl
walk(e, props, href || linkHref);
continue;
}
case 'ruby': {
// Both halves are elements of their own; the editor keeps them as one atom.
const base = e.getElementsByTagNameNS(NS.text, 'ruby-base')[0]?.textContent ?? '';
const reading = e.getElementsByTagNameNS(NS.text, 'ruby-text')[0]?.textContent ?? '';
if (base.trim()) out.push({ type: 'ruby', attrs: { base: base.trim(), text: reading.trim() } });
continue;
}
case 'note': {
const note = convertNote(e, ctx, defaults);
if (note) out.push(note);
+10
View File
@@ -184,6 +184,16 @@
background: rgba(255, 145, 0, 0.8);
}
/* A ruby annotation (ruby.ts). The reading sits above its base at half its size,
which is what both word processors print and what `w:hps` says. */
.paper .tiptap ruby {
ruby-position: over;
}
.paper .tiptap ruby rt {
font-size: 0.5em;
line-height: 1;
}
/* The word being offered (wordCompletion.ts): after the caret, in the text's own
font, greyed — it is not in the document until Enter takes it. */
.paper .tiptap .word-completion {
+49
View File
@@ -0,0 +1,49 @@
// A ruby annotation is one element in both formats — a base and the reading over it —
// so it travels as one atom. ODF writes <text:ruby> with a ruby-family style,
// Word a <w:ruby> run.
import { describe, it, expect } from 'vitest';
import { unzipSync, strFromU8 } from 'fflate';
import { buildOdt } from '../../src/lib/export/odt';
import { buildDocx } from '../../src/lib/export/docx';
import { importOdt } from '../../src/lib/import/odt';
import { importDocx } from '../../src/lib/import/docx';
type N = any;
const doc: N = {
type: 'doc',
content: [{
type: 'paragraph',
content: [
{ type: 'text', text: 'Vor ' },
{ type: 'ruby', attrs: { base: '漢字', text: 'かんじ' } },
{ type: 'text', text: ' nach.' },
],
}],
};
const margins = { top: 2, bottom: 2, left: 2, right: 2 };
// The imported paragraph's inline children, flattened to what this test cares about.
const inline = (r: N): N[] => ((r.content as N).content[0].content ?? []);
describe('ruby', () => {
it('round-trips through ODF', async () => {
const bytes = await buildOdt(doc, margins, 'portrait');
const xml = strFromU8(unzipSync(bytes)['content.xml']);
expect(xml).toContain('<text:ruby-base>漢字</text:ruby-base><text:ruby-text>かんじ</text:ruby-text>');
expect(xml).toContain('style:family="ruby"');
const back = inline(importOdt(bytes));
expect(back.find((n) => n.type === 'ruby')?.attrs).toEqual({ base: '漢字', text: 'かんじ' });
expect(back.map((n) => n.text ?? '').join('')).toBe('Vor nach.');
});
it('round-trips through DOCX', async () => {
const bytes = await buildDocx(doc, margins, 'portrait');
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
expect(xml).toContain('<w:rubyBase><w:r><w:t xml:space="preserve">漢字</w:t></w:r></w:rubyBase>');
expect(xml).toContain('<w:rubyPr>');
const back = inline(importDocx(bytes));
expect(back.find((n) => n.type === 'ruby')?.attrs).toEqual({ base: '漢字', text: 'かんじ' });
});
});