mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
fix: read Office attachments in their own order, text boxes once and dates as dates
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { strToU8, zipSync, type Zippable } from 'fflate';
|
||||
import { extractText } from '@/lib/attachment-text';
|
||||
|
||||
// What an Office file means rather than how it is stored: text boxes once,
|
||||
// moved text once, slides in the order the deck shows them, the main part
|
||||
// wherever the package says it is, dates as dates.
|
||||
|
||||
const zip = (files: Record<string, string>): Uint8Array => {
|
||||
const entries: Zippable = {};
|
||||
for (const [name, value] of Object.entries(files)) entries[name] = strToU8(value);
|
||||
return zipSync(entries);
|
||||
};
|
||||
|
||||
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:v="urn:schemas-microsoft-com:vml"';
|
||||
const PKG = 'xmlns="http://schemas.openxmlformats.org/package/2006/relationships"';
|
||||
const OFFICE_DOC = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument';
|
||||
|
||||
describe('Word', () => {
|
||||
it('reads a text box once, on its own line, and moved text once', async () => {
|
||||
const box = (text: string) => `<w:txbxContent><w:p><w:r><w:t>${text}</w:t></w:r></w:p></w:txbxContent>`;
|
||||
const doc = zip({
|
||||
'word/document.xml': `<w:document ${W}><w:body>
|
||||
<w:p><w:r><w:t>Before the box.</w:t></w:r><w:r><mc:AlternateContent>
|
||||
<mc:Choice Requires="wps"><w:drawing><wps:wsp><wps:txbx>${box('Box text')}</wps:txbx></wps:wsp></w:drawing></mc:Choice>
|
||||
<mc:Fallback><w:pict><v:shape><v:textbox>${box('Box text')}</v:textbox></v:shape></w:pict></mc:Fallback>
|
||||
</mc:AlternateContent></w:r></w:p>
|
||||
<w:p><w:moveFrom><w:r><w:t>Old place</w:t></w:r></w:moveFrom><w:moveTo><w:r><w:t>New place</w:t></w:r></w:moveTo></w:p>
|
||||
</w:body></w:document>`,
|
||||
});
|
||||
expect((await extractText(doc, { name: 'box.docx' })).text).toBe('Before the box.\nBox text\nNew place');
|
||||
});
|
||||
|
||||
it('finds the main part where the package says it is', async () => {
|
||||
const doc = zip({
|
||||
'_rels/.rels': `<Relationships ${PKG}><Relationship Id="rId1" Type="${OFFICE_DOC}" Target="/word/main.xml"/></Relationships>`,
|
||||
'word/main.xml': `<w:document ${W}><w:body><w:p><w:r><w:t>From the main part</w:t></w:r></w:p></w:body></w:document>`,
|
||||
});
|
||||
expect(await extractText(doc, { name: 'moved.docx' })).toMatchObject({ status: 'ok', text: 'From the main part' });
|
||||
});
|
||||
});
|
||||
|
||||
const P = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"';
|
||||
const shape = (text: string) => `<p:sp><p:txBody><a:p><a:r><a:t>${text}</a:t></a:r></a:p></p:txBody></p:sp>`;
|
||||
const slide = (body: string) => `<p:sld ${P}><p:cSld><p:spTree>${body}</p:spTree></p:cSld></p:sld>`;
|
||||
|
||||
describe('PowerPoint', () => {
|
||||
it('reads the slides in the order of the deck, not of the files', async () => {
|
||||
const deck = zip({
|
||||
'_rels/.rels': `<Relationships ${PKG}><Relationship Id="rId1" Type="${OFFICE_DOC}" Target="ppt/presentation.xml"/></Relationships>`,
|
||||
'ppt/presentation.xml': `<p:presentation ${P}><p:sldIdLst><p:sldId id="256" r:id="rId8"/><p:sldId id="257" r:id="rId7"/></p:sldIdLst></p:presentation>`,
|
||||
'ppt/_rels/presentation.xml.rels': `<Relationships ${PKG}><Relationship Id="rId7" Target="slides/slide1.xml"/><Relationship Id="rId8" Target="slides/slide2.xml"/></Relationships>`,
|
||||
'ppt/slides/slide1.xml': slide(shape('Shown second')),
|
||||
'ppt/slides/slide2.xml': slide(shape('Shown first')),
|
||||
});
|
||||
expect((await extractText(deck, { name: 'deck.pptx' })).text).toBe('Slide 1\nShown first\n\nSlide 2\nShown second');
|
||||
});
|
||||
|
||||
it('reads a shape with a fallback copy once', async () => {
|
||||
const deck = zip({
|
||||
'ppt/presentation.xml': `<p:presentation ${P}/>`,
|
||||
'ppt/slides/slide1.xml': slide(`<mc:AlternateContent><mc:Choice Requires="p14">${shape('Shape text')}</mc:Choice><mc:Fallback>${shape('Shape text')}</mc:Fallback></mc:AlternateContent>`),
|
||||
});
|
||||
expect((await extractText(deck, { name: 'deck.pptx' })).text).toBe('Slide 1\nShape text');
|
||||
});
|
||||
});
|
||||
|
||||
const S = 'xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
|
||||
const book = (cells: string, { date1904 = false } = {}) => zip({
|
||||
'xl/workbook.xml': `<workbook ${S}>${date1904 ? '<workbookPr date1904="1"/>' : ''}<sheets><sheet name="Dates" sheetId="1" r:id="rId1"/></sheets></workbook>`,
|
||||
'xl/_rels/workbook.xml.rels': `<Relationships ${PKG}><Relationship Id="rId1" Target="worksheets/sheet1.xml"/></Relationships>`,
|
||||
'xl/styles.xml': `<styleSheet ${S}><numFmts count="2"><numFmt numFmtId="164" formatCode="dd/mm/yyyy\\ hh:mm"/><numFmt numFmtId="165" formatCode="0.00" days""/></numFmts>
|
||||
<cellXfs count="5"><xf numFmtId="0"/><xf numFmtId="14"/><xf numFmtId="164"/><xf numFmtId="165"/><xf numFmtId="20"/></cellXfs></styleSheet>`,
|
||||
'xl/worksheets/sheet1.xml': `<worksheet ${S}><sheetData><row r="1">${cells}</row></sheetData></worksheet>`,
|
||||
});
|
||||
|
||||
describe('Excel dates', () => {
|
||||
it('writes date cells as ISO dates, by built-in and custom formats', async () => {
|
||||
const out = await extractText(book('<c r="A1" s="1"><v>46326</v></c><c r="B1" s="2"><v>46326.5</v></c><c r="C1" s="0"><v>46326</v></c><c r="D1" s="3"><v>46326</v></c><c r="E1" s="4"><v>0.75</v></c>'), { name: 'dates.xlsx' });
|
||||
expect(out.text).toBe('Sheet: Dates\n2026-10-31,2026-10-31 12:00,46326,46326,18:00');
|
||||
});
|
||||
|
||||
it('counts from 1904 when the workbook says so', async () => {
|
||||
const out = await extractText(book('<c r="A1" s="1"><v>44864</v></c>', { date1904: true }), { name: 'mac.xlsx' });
|
||||
expect(out.text).toBe('Sheet: Dates\n2026-10-31');
|
||||
});
|
||||
});
|
||||
+160
-39
@@ -103,6 +103,12 @@ function attr(el: Element, local: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The r:id of a reference to another part (an attribute named id in a namespace, not a plain id). */
|
||||
function relId(el: Element): string | null {
|
||||
for (const a of Array.from(el.attributes)) if (a.localName === 'id' && a.namespaceURI) return a.value;
|
||||
return null;
|
||||
}
|
||||
|
||||
const cell = (text: string) => (text.length > MAX_CELL_CHARS ? text.slice(0, MAX_CELL_CHARS) : text);
|
||||
|
||||
/** A cell for CSV-like rows: quoted when it holds a comma, a quote or a line break. */
|
||||
@@ -116,18 +122,88 @@ function csvRow(cells: string[]): string {
|
||||
return cells.slice(0, end).map(csvCell).join(',');
|
||||
}
|
||||
|
||||
// ─── Package parts (docx, xlsx, pptx) ────────────────────────
|
||||
|
||||
function normalisePath(path: string): string {
|
||||
const parts: string[] = [];
|
||||
for (const part of path.split('/')) {
|
||||
if (part === '..') parts.pop();
|
||||
else if (part && part !== '.') parts.push(part);
|
||||
}
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
interface Relation { target: string; type: string }
|
||||
|
||||
/** The relations of `part` (its _rels/<name>.rels next to it): rId → target path and type. */
|
||||
async function relationsOf(zip: Unpacker, part: string): Promise<Map<string, Relation>> {
|
||||
const slash = part.lastIndexOf('/');
|
||||
const dir = slash === -1 ? '' : part.slice(0, slash);
|
||||
const relsPath = part === '' ? '_rels/.rels' : `${dir ? `${dir}/` : ''}_rels/${part.slice(slash + 1)}.rels`;
|
||||
const rels = await zip.xml(relsPath);
|
||||
const map = new Map<string, Relation>();
|
||||
if (!rels) return map;
|
||||
for (const rel of all(rels, 'Relationship')) {
|
||||
const id = rel.getAttribute('Id');
|
||||
const target = rel.getAttribute('Target');
|
||||
if (!id || !target || rel.getAttribute('TargetMode') === 'External') continue;
|
||||
map.set(id, {
|
||||
target: target.startsWith('/') ? normalisePath(target) : normalisePath(`${dir}/${target}`),
|
||||
type: rel.getAttribute('Type') ?? '',
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** The package's main part (document, workbook, presentation), as its _rels/.rels names it. */
|
||||
async function mainPart(zip: Unpacker, fallback: string): Promise<string> {
|
||||
for (const rel of (await relationsOf(zip, '')).values()) {
|
||||
if (rel.type.endsWith('/officeDocument')) return rel.target;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Elements of `local` name under `root`, in document order, leaving out mc:Fallback copies. */
|
||||
function without(root: Element, local: string, skip: ReadonlySet<string> = FALLBACK): Element[] {
|
||||
const out: Element[] = [];
|
||||
const walk = (el: Element) => {
|
||||
for (const c of Array.from(el.children)) {
|
||||
if (skip.has(c.localName)) continue;
|
||||
if (c.localName === local) out.push(c);
|
||||
else walk(c);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return out;
|
||||
}
|
||||
const FALLBACK: ReadonlySet<string> = new Set(['Fallback']);
|
||||
|
||||
// ─── Word (docx) ─────────────────────────────────────────────
|
||||
|
||||
function wordRuns(el: Element): string {
|
||||
/**
|
||||
* Not part of what the reader sees: deleted and moved-away text of tracked
|
||||
* changes, field codes, and the Fallback copy of anything in
|
||||
* mc:AlternateContent (the Choice holds the same).
|
||||
*/
|
||||
const WORD_SKIP: ReadonlySet<string> = new Set(['delText', 'instrText', 'del', 'moveFrom', 'Fallback', 'sectPr']);
|
||||
|
||||
/** A paragraph's text; the paragraphs of text boxes in it go to `boxes`, each its own line. */
|
||||
function wordRuns(el: Element, boxes: string[]): string {
|
||||
let out = '';
|
||||
for (const c of Array.from(el.children)) {
|
||||
if (WORD_SKIP.has(c.localName)) continue;
|
||||
switch (c.localName) {
|
||||
case 't': out += c.textContent ?? ''; break;
|
||||
case 'tab': out += '\t'; break;
|
||||
case 'br': case 'cr': out += '\n'; break;
|
||||
// Deleted text of tracked changes, field codes: not part of what the reader sees.
|
||||
case 'delText': case 'instrText': case 'del': break;
|
||||
default: out += wordRuns(c);
|
||||
case 'txbxContent': {
|
||||
const inner = new TextSink(MAX_CELL_CHARS);
|
||||
wordBlocks(c, inner);
|
||||
const text = inner.text();
|
||||
if (text) boxes.push(text);
|
||||
break;
|
||||
}
|
||||
default: out += wordRuns(c, boxes);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -136,18 +212,28 @@ function wordRuns(el: Element): string {
|
||||
function wordBlocks(el: Element, out: TextSink): void {
|
||||
for (const c of Array.from(el.children)) {
|
||||
if (out.full) return;
|
||||
if (c.localName === 'p') out.line(wordRuns(c));
|
||||
else if (c.localName === 'tbl') {
|
||||
if (WORD_SKIP.has(c.localName)) continue;
|
||||
if (c.localName === 'p') {
|
||||
const boxes: string[] = [];
|
||||
const text = wordRuns(c, boxes);
|
||||
if (text || !boxes.length) out.line(text);
|
||||
for (const box of boxes) out.line(box);
|
||||
} else if (c.localName === 'tbl') {
|
||||
for (const row of kids(c, 'tr')) {
|
||||
if (!out.line(kids(row, 'tc').map((td) => cell(all(td, 'p').map(wordRuns).join(' ').trim())).join(' | '))) return;
|
||||
const cells = kids(row, 'tc').map((td) => {
|
||||
const inner = new TextSink(MAX_CELL_CHARS);
|
||||
wordBlocks(td, inner);
|
||||
return inner.text().replace(/\s*\n\s*/g, ' ');
|
||||
});
|
||||
if (!out.line(cells.join(' | '))) return;
|
||||
}
|
||||
out.line('');
|
||||
} else if (c.localName !== 'sectPr') wordBlocks(c, out);
|
||||
} else wordBlocks(c, out);
|
||||
}
|
||||
}
|
||||
|
||||
async function docx(zip: Unpacker, sink: TextSink): Promise<Result> {
|
||||
const doc = await zip.xml('word/document.xml');
|
||||
const doc = await zip.xml(await mainPart(zip, 'word/document.xml'));
|
||||
if (!doc) return { status: 'failed', text: '', truncated: false };
|
||||
wordBlocks(all(doc, 'body')[0] ?? doc.documentElement, sink);
|
||||
return { status: 'ok', text: sink.text(), truncated: sink.full };
|
||||
@@ -164,49 +250,70 @@ function columnOf(ref: string | null): number | null {
|
||||
return n - 1;
|
||||
}
|
||||
|
||||
/** rId → target path inside the archive, from a .rels file next to `base`. */
|
||||
async function relations(zip: Unpacker, relsPath: string, baseDir: string): Promise<Map<string, string>> {
|
||||
const rels = await zip.xml(relsPath);
|
||||
const map = new Map<string, string>();
|
||||
if (!rels) return map;
|
||||
for (const rel of all(rels, 'Relationship')) {
|
||||
const id = rel.getAttribute('Id');
|
||||
const target = rel.getAttribute('Target');
|
||||
if (!id || !target) continue;
|
||||
map.set(id, target.startsWith('/') ? target.slice(1) : normalisePath(`${baseDir}/${target}`));
|
||||
}
|
||||
return map;
|
||||
/** Built-in number formats that are dates or times (ECMA-376 18.8.30). */
|
||||
const DATE_FORMAT_IDS = new Set([14, 15, 16, 17, 18, 19, 20, 21, 22, 45, 46, 47]);
|
||||
|
||||
/** True for a custom format code that shows a date or a time (d, m with y, y, h, s outside quotes and brackets). */
|
||||
export function isDateFormatCode(code: string): boolean {
|
||||
const bare = code.replace(/"[^"]*"/g, '').replace(/\\./g, '').replace(/\[[^\]]*\]/g, '');
|
||||
return /[dyhs]/i.test(bare);
|
||||
}
|
||||
|
||||
function normalisePath(path: string): string {
|
||||
const parts: string[] = [];
|
||||
for (const part of path.split('/')) {
|
||||
if (part === '..') parts.pop();
|
||||
else if (part && part !== '.') parts.push(part);
|
||||
}
|
||||
return parts.join('/');
|
||||
/** The cell styles (index in cellXfs) whose number format is a date or a time. */
|
||||
async function dateStyles(zip: Unpacker, path: string): Promise<Set<number>> {
|
||||
const out = new Set<number>();
|
||||
const styles = await zip.xml(path);
|
||||
if (!styles) return out;
|
||||
const custom = new Map<number, string>();
|
||||
for (const fmt of all(styles, 'numFmt')) custom.set(Number(fmt.getAttribute('numFmtId')), fmt.getAttribute('formatCode') ?? '');
|
||||
const xfs = all(styles, 'cellXfs')[0];
|
||||
kids(xfs ?? styles.documentElement, 'xf').forEach((xf, index) => {
|
||||
if (!xfs) return;
|
||||
const id = Number(xf.getAttribute('numFmtId') ?? 0);
|
||||
const code = custom.get(id);
|
||||
if (code !== undefined ? isDateFormatCode(code) : DATE_FORMAT_IDS.has(id)) out.add(index);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A date serial as "2026-10-31", "2026-10-31 12:00" or, below one day, "12:00". */
|
||||
export function excelDate(serial: number, date1904 = false): string | null {
|
||||
if (!Number.isFinite(serial) || serial < 0 || serial > 2_958_465) return null;
|
||||
const days = Math.floor(serial);
|
||||
const seconds = Math.round((serial - days) * 86400);
|
||||
// 1899-12-30 makes Excel's day 60 (a 29 February 1900 that never was) come out right after it.
|
||||
const base = date1904 ? Date.UTC(1904, 0, 1) : Date.UTC(1899, 11, 30);
|
||||
const iso = new Date(base + days * 86_400_000 + seconds * 1000).toISOString();
|
||||
if (days === 0 && !date1904) return iso.slice(11, 16);
|
||||
return seconds ? `${iso.slice(0, 10)} ${iso.slice(11, 16)}` : iso.slice(0, 10);
|
||||
}
|
||||
|
||||
async function xlsx(zip: Unpacker, sink: TextSink): Promise<Result> {
|
||||
const book = await zip.xml('xl/workbook.xml');
|
||||
const bookPath = await mainPart(zip, 'xl/workbook.xml');
|
||||
const book = await zip.xml(bookPath);
|
||||
if (!book) return { status: 'failed', text: '', truncated: false };
|
||||
const rels = await relations(zip, 'xl/_rels/workbook.xml.rels', 'xl');
|
||||
const rels = await relationsOf(zip, bookPath);
|
||||
const dir = bookPath.includes('/') ? bookPath.slice(0, bookPath.lastIndexOf('/')) : '';
|
||||
const related = (type: string, fallback: string) => [...rels.values()].find((r) => r.type.endsWith(type))?.target ?? fallback;
|
||||
const shared: string[] = [];
|
||||
const strings = await zip.xml('xl/sharedStrings.xml');
|
||||
const strings = await zip.xml(related('/sharedStrings', `${dir}/sharedStrings.xml`));
|
||||
if (strings) {
|
||||
for (const si of all(strings, 'si')) {
|
||||
// Phonetic runs (rPh) are reading aids, not the cell's text.
|
||||
shared.push(cell(all(si, 't').filter((t) => t.parentElement?.localName !== 'rPh').map((t) => t.textContent ?? '').join('')));
|
||||
}
|
||||
}
|
||||
const dates = await dateStyles(zip, related('/styles', `${dir}/styles.xml`));
|
||||
const pr = all(book, 'workbookPr')[0];
|
||||
const date1904 = pr?.getAttribute('date1904') === '1' || pr?.getAttribute('date1904') === 'true';
|
||||
const sheets = all(book, 'sheet');
|
||||
const names: string[] = [];
|
||||
let truncated = sheets.length > MAX_SHEETS;
|
||||
for (const sheet of sheets.slice(0, MAX_SHEETS)) {
|
||||
if (sink.full) break;
|
||||
const name = sheet.getAttribute('name') ?? `Sheet ${names.length + 1}`;
|
||||
const rid = attr(sheet, 'id');
|
||||
const path = rid ? rels.get(rid) : undefined;
|
||||
const rid = relId(sheet);
|
||||
const path = rid ? rels.get(rid)?.target : undefined;
|
||||
const doc = path ? await zip.xml(path) : null;
|
||||
names.push(name);
|
||||
if (names.length > 1) sink.line('');
|
||||
@@ -225,6 +332,7 @@ async function xlsx(zip: Unpacker, sink: TextSink): Promise<Result> {
|
||||
if (type === 's') value = shared[Number(v)] ?? '';
|
||||
else if (type === 'inlineStr') value = cell(all(c, 't').map((t) => t.textContent ?? '').join(''));
|
||||
else if (type === 'b') value = v === '1' ? 'TRUE' : v === '0' ? 'FALSE' : v;
|
||||
else if ((!type || type === 'n') && v && dates.has(Number(c.getAttribute('s') ?? 0))) value = excelDate(Number(v), date1904) ?? v;
|
||||
else value = cell(v);
|
||||
while (cells.length < col) cells.push('');
|
||||
cells[col] = value;
|
||||
@@ -238,19 +346,32 @@ async function xlsx(zip: Unpacker, sink: TextSink): Promise<Result> {
|
||||
|
||||
// ─── PowerPoint (pptx) ───────────────────────────────────────
|
||||
|
||||
async function pptx(zip: Unpacker, sink: TextSink): Promise<Result> {
|
||||
const slides = zip.entries
|
||||
/** The slides in the order the deck shows them (sldIdLst), else by their file numbers. */
|
||||
async function slidePaths(zip: Unpacker, presentation: string): Promise<string[]> {
|
||||
const pres = await zip.xml(presentation);
|
||||
if (pres) {
|
||||
const rels = await relationsOf(zip, presentation);
|
||||
const ordered = all(pres, 'sldId').map((s) => rels.get(relId(s) ?? '')?.target).filter((p): p is string => !!p);
|
||||
if (ordered.length) return ordered;
|
||||
}
|
||||
return zip.entries
|
||||
.map((e) => ({ name: e.name, n: Number(/^ppt\/slides\/slide(\d+)\.xml$/.exec(e.name)?.[1]) }))
|
||||
.filter((s) => Number.isFinite(s.n))
|
||||
.sort((a, b) => a.n - b.n);
|
||||
if (!slides.length && !zip.has('ppt/presentation.xml')) return { status: 'failed', text: '', truncated: false };
|
||||
.sort((a, b) => a.n - b.n)
|
||||
.map((s) => s.name);
|
||||
}
|
||||
|
||||
async function pptx(zip: Unpacker, sink: TextSink): Promise<Result> {
|
||||
const presentation = await mainPart(zip, 'ppt/presentation.xml');
|
||||
const slides = await slidePaths(zip, presentation);
|
||||
if (!slides.length && !zip.has(presentation)) return { status: 'failed', text: '', truncated: false };
|
||||
for (const [i, slide] of slides.entries()) {
|
||||
if (sink.full) break;
|
||||
const doc = await zip.xml(slide.name);
|
||||
const doc = await zip.xml(slide);
|
||||
if (!doc) continue;
|
||||
if (i > 0) sink.line('');
|
||||
sink.line(`Slide ${i + 1}`);
|
||||
for (const p of all(doc, 'p')) {
|
||||
for (const p of without(doc.documentElement, 'p')) {
|
||||
const text = all(p, 't').map((t) => t.textContent ?? '').join('').trim();
|
||||
if (text && !sink.line(text)) break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user