fix: a Word bibliography no longer imports twice

Word caches a BIBLIOGRAPHY field's rows as a w:tbl inside the field, and
only the field's paragraphs were skipped — so the list arrived once as the
regenerated index node and once as that table. Measured on a 49-page
document: two pages of duplicate. StyleName IEEE now reads as the numbered
style it is, so its citations show [1] instead of the source key.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:02 +02:00
1 parent f1f9a4df15
commit 198c16f7ab
4 files changed
+68 -2

No files matched your search

+1 -1
View File
@@ -37,7 +37,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
- **A cell's number format** is on its *style* in ODF (`style:data-style-name` → a `number:number-style`/`percentage-style`/`currency-style`/`date-style`, read for its family, decimals and grouping) and on the field in DOCX (the `\#` picture, or `\@` for a date, which is not part of the formula and is cut out of it). Only a formula cell keeps one, and the file's own currency symbol and date order give way to the document language's.
- **Vertical text in a box** is the style's writing mode in ODF (a frame's graphic properties — where LibreOffice writes it — or a drawing shape's paragraph properties; both top-to-bottom modes) and `w:bodyPr vert` in DOCX (`vert`/`eaVert`/`mongolianVert`/`wordArtVert`; `vert270` reads bottom-to-top, which the browser cannot lay out, so it is not one). VML says the same as `layout-flow:vertical` on the textbox.
- **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.
- **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 — and Word caches that field's rows as a **`w:tbl`** inside the field, so every block between the field's `separate` and its `end` is skipped, not only the paragraphs; imported, that table printed the whole list a second time (measured: two pages). `b:Sources StyleName` picks the citation style (`citationStyleFromDocx`).
- **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. Whether the document goes on **recording** is read too (`recordChanges`): the registry's `text:track-changes`, which ODF defaults to true where the element carries none, and Word's `w:trackRevisions` on/off in settings.xml.
- **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`.
- **Graceful degradation** (collected as `warnings`, surfaced once): comments dropped, hyperlinks flattened to text, nested tables flattened to paragraphs, non-`Pictures` drawings dropped, unreadable pictures skipped (each with a warning). Page size is normalized to A4. Floating/wrapped images are imported as left/right/top-bottom wrap (free x/y positions collapse to the nearest side; see `image.ts`).
+4
View File
@@ -573,6 +573,10 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef
out.push(...blocks);
breakPending = trailingBreak;
}
} else if (tocState.tocDepth >= 0) {
// Still inside an open TOC/INDEX/BIBLIOGRAPHY field: whatever carries its cached
// result is skipped, the paragraphs above and a bibliography's table alike.
continue;
} else if (el.localName === 'tbl') {
breakPending = false; // a break before a table can't be modeled; drop it
flush();
+1 -1
View File
@@ -22,7 +22,7 @@ export const DOCX_STYLE_NAME: Record<CitationStyle, string> = {
const FROM_DOCX_STYLE: Record<string, CitationStyle> = {
APA: 'apa', MLA: 'mla', Chicago: 'chicago', ChicagoAuthorDate: 'chicago',
ISO690Numerical: 'numbered', IEEE2006: 'numbered',
ISO690Numerical: 'numbered', IEEE: 'numbered', IEEE2006: 'numbered',
};
export function citationStyleFromDocx(name: string): CitationStyle | null {
@@ -0,0 +1,62 @@
// Word writes a BIBLIOGRAPHY field's cached rows as a borderless w:tbl inside the field.
// The index node is regenerated, so that table is the field's result and not content —
// imported it would print the whole list a second time.
import { describe, it, expect } from 'vitest';
import { zipSync, strToU8 } from 'fflate';
import { importDocx } from '../../src/lib/import/docx';
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
const B = 'http://schemas.openxmlformats.org/officeDocument/2006/bibliography';
const CT = `<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>`;
const RELS = `<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>`;
const sources = (styleName: string) => `<?xml version="1.0"?><b:Sources xmlns:b="${B}" StyleName="${styleName}">
<b:Source><b:Tag>Kaf25</b:Tag><b:SourceType>Book</b:SourceType><b:Title>Der Process</b:Title><b:Year>1925</b:Year>
<b:Author><b:Author><b:NameList><b:Person><b:Last>Kafka</b:Last><b:First>Franz</b:First></b:Person></b:NameList></b:Author></b:Author>
</b:Source></b:Sources>`;
// The shape Word writes: a Bibliographies content control holding the heading, then the
// field, whose cached result between `separate` and `end` is a table.
const DOCUMENT = `<?xml version="1.0"?><w:document ${W}><w:body>
<w:sdt><w:sdtPr><w:docPartObj><w:docPartGallery w:val="Bibliographies"/></w:docPartObj></w:sdtPr><w:sdtContent>
<w:p><w:r><w:t>Literaturverzeichnis</w:t></w:r></w:p>
<w:sdt><w:sdtPr><w:bibliography/></w:sdtPr><w:sdtContent>
<w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>BIBLIOGRAPHY</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r></w:p>
<w:tbl><w:tr><w:tc><w:p><w:r><w:t>[1] F. Kafka, Der Process, 1925.</w:t></w:r></w:p></w:tc></w:tr></w:tbl>
<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>
</w:sdtContent></w:sdt>
</w:sdtContent></w:sdt>
</w:body></w:document>`;
const build = (styleName: string) => zipSync({
'[Content_Types].xml': strToU8(CT),
'_rels/.rels': strToU8(RELS),
'word/document.xml': strToU8(DOCUMENT),
'customXml/item1.xml': strToU8(sources(styleName)),
});
const types = (doc: { content?: { type: string }[] }): string[] => (doc.content ?? []).map((b) => b.type);
describe('a BIBLIOGRAPHY field', () => {
it('becomes one index node, its cached table dropped', () => {
const doc = importDocx(build('IEEE')).content as unknown as { content?: { type: string }[] };
expect(types(doc)).toEqual(['paragraph', 'tableOfContents']);
});
it('takes the numbered style IEEE names', () => {
const doc = importDocx(build('IEEE')).content as unknown as { content?: { type: string; attrs?: Record<string, unknown> }[] };
expect(doc.content![1].attrs).toMatchObject({ index: 'bibliography', citationStyle: 'numbered' });
});
it('falls back to cite-by-key where the style is one we have not got', () => {
const doc = importDocx(build('Gost-Name')).content as unknown as { content?: { attrs?: Record<string, unknown> }[] };
expect(doc.content![1].attrs).toMatchObject({ citationStyle: 'key' });
});
});