diff --git a/App/frontend/desktop/src/lib/office-preview.ts b/App/frontend/desktop/src/lib/office-preview.ts index 0d9cf993a..458391e85 100644 --- a/App/frontend/desktop/src/lib/office-preview.ts +++ b/App/frontend/desktop/src/lib/office-preview.ts @@ -7,7 +7,9 @@ * that the preview renders as React nodes rather than injected HTML. * * Only what a preview needs is extracted: text, heading level, bold/italic, - * tables and cell values. Styling, images, charts and formulas are ignored. + * paragraph indent, spacing and alignment, tables (including merged cells and + * column widths), embedded images, and drawing canvases (positioned boxes, + * arrows and connectors). Charts and formulas are ignored. */ import JSZip from "jszip"; @@ -15,6 +17,11 @@ const WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; const SHEET_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; const RELS_NS = "http://schemas.openxmlformats.org/package/2006/relationships"; const OFFICE_RELS_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; +const VML_NS = "urn:schemas-microsoft-com:vml"; +const OFFICE_VML_NS = "urn:schemas-microsoft-com:office:office"; +/** English Metric Units per point. Drawing extents are stored in EMUs. */ +const EMU_PER_PT = 12_700; +const DRAWING_ANCESTORS = new Set(["drawing", "pict", "txbxContent", "textbox"]); /** Excel's epoch is 1899-12-30 because it treats 1900 as a leap year. */ const EXCEL_EPOCH_MS = Date.UTC(1899, 11, 30); @@ -28,9 +35,66 @@ export interface DocxTextSpan { italic: boolean; } +export interface DocxTableCell { + content: DocxBlock[]; + colSpan: number; + rowSpan: number; +} + +/** Paragraph box metrics taken from `w:pPr` and the document styles. Lengths are twips unless noted. */ +export interface DocxParagraphFormat { + align?: "left" | "center" | "right" | "justify"; + /** Left indent. */ + indentLeft?: number; + /** Right indent. */ + indentRight?: number; + /** Left indent in hundredths of a character. Used instead of `indentLeft` when set. */ + indentLeftChars?: number; + /** Right indent in hundredths of a character. Used instead of `indentRight` when set. */ + indentRightChars?: number; + /** First-line indent. Negative when the paragraph uses a hanging indent. */ + firstLine?: number; + /** First-line indent in hundredths of a character. Negative when hanging. Used instead of `firstLine` when set. */ + firstLineChars?: number; + /** Space before the paragraph. */ + before?: number; + /** Space after the paragraph. */ + after?: number; + /** Space before, in hundredths of a line. Used instead of `before` when set. */ + beforeLines?: number; + /** Space after, in hundredths of a line. Used instead of `after` when set. */ + afterLines?: number; + /** + * Line spacing. With `lineRule: "auto"` this is in 240ths of a line + * (240 is single spacing). Otherwise it is twips. + */ + line?: number; + lineRule?: "auto" | "exact" | "atLeast"; +} + +/** One shape on a drawing canvas. Positions are percentages of that canvas. */ +export interface DocxDrawingShape { + left: number; + top: number; + width: number; + height: number; + role: "box" | "round" | "diamond" | "label" | "arrow-h" | "arrow-v" | "line" | "elbow"; + text: string; + startArrow: boolean; + endArrow: boolean; + flipX: boolean; + flipY: boolean; + /** Clockwise degrees. */ + rotation: number; + /** Bend position of an elbow connector, as a fraction of its box. */ + adj: number; +} + export type DocxBlock = - | { kind: "paragraph"; headingLevel: number | null; spans: DocxTextSpan[] } - | { kind: "table"; rows: DocxTextSpan[][][] }; + | { kind: "paragraph"; headingLevel: number | null; spans: DocxTextSpan[]; format?: DocxParagraphFormat } + | { kind: "table"; columnWidths: number[]; rows: DocxTableCell[][] } + | { kind: "image"; src: string; widthPt: number; heightPt: number } + | { kind: "drawing"; widthPt: number; heightPt: number; shapes: DocxDrawingShape[] }; export interface XlsxSheet { name: string; @@ -48,31 +112,229 @@ export async function readDocxBlocks(data: ArrayBuffer | Blob): Promise; +} + +/** Reads paragraphs and tables, descending into content controls and revisions. */ +function readDocxFlow(container: Element, styles: DocxStyleSheet, images: ReadonlyMap): DocxBlock[] { const blocks: DocxBlock[] = []; - for (const node of Array.from(body.childNodes)) { - if (node.nodeType !== 1) continue; - const element = node as Element; - if (element.namespaceURI !== WORD_NS) continue; + for (const element of wordChildren(container)) { if (element.localName === "p") { - const spans = readDocxParagraphSpans(element); - blocks.push({ kind: "paragraph", headingLevel: readDocxHeadingLevel(element), spans }); - continue; - } - if (element.localName === "tbl") { - // Direct children only, so a nested table's rows are not hoisted into the outer table. - const rows = directChildren(element, WORD_NS, "tr").map((row) => ( - directChildren(row, WORD_NS, "tc").map((cell) => ( - directChildren(cell, WORD_NS, "p").flatMap((paragraph) => readDocxParagraphSpans(paragraph)) - )) - )); - if (rows.length) blocks.push({ kind: "table", rows }); + blocks.push(...readDocxParagraph(element, styles, images)); + } else if (element.localName === "tbl") { + const table = readDocxTable(element, styles, images); + if (table.rows.length) blocks.push(table); + } else if (element.localName === "sdt") { + const content = directChildren(element, WORD_NS, "sdtContent")[0]; + if (content) blocks.push(...readDocxFlow(content, styles, images)); + } else if (element.localName === "ins" || element.localName === "moveTo") { + blocks.push(...readDocxFlow(element, styles, images)); } } return blocks; } +function readDocxTable(table: Element, styles: DocxStyleSheet, images: ReadonlyMap): Extract { + const grid = directChildren(table, WORD_NS, "tblGrid")[0]; + const columnWidths = directChildren(grid ?? null, WORD_NS, "gridCol").map((column) => { + const width = Number(namespacedAttribute(column, WORD_NS, "w", "w")); + return Number.isFinite(width) && width > 0 ? width : 0; + }); + const rows = directChildren(table, WORD_NS, "tr").map((row) => ( + directChildren(row, WORD_NS, "tc").map((cell) => readDocxCell(cell, styles, images)) + )); + collapseVerticalMerges(rows); + return { kind: "table", columnWidths, rows }; +} + +interface DocxTableCellDraft extends DocxTableCell { + verticalMerge?: "restart" | "continue"; +} + +function readDocxCell(cell: Element, styles: DocxStyleSheet, images: ReadonlyMap): DocxTableCellDraft { + const properties = directChildren(cell, WORD_NS, "tcPr")[0]; + const spanElement = directChildren(properties ?? null, WORD_NS, "gridSpan")[0]; + const spanValue = spanElement ? Number(namespacedAttribute(spanElement, WORD_NS, "val", "w")) : Number.NaN; + const mergeElement = directChildren(properties ?? null, WORD_NS, "vMerge")[0]; + const mergeValue = mergeElement ? (namespacedAttribute(mergeElement, WORD_NS, "val", "w") ?? "continue") : null; + return { + content: readDocxFlow(cell, styles, images), + colSpan: Number.isInteger(spanValue) && spanValue > 1 ? spanValue : 1, + rowSpan: 1, + ...(mergeValue === "restart" || mergeValue === "continue" ? { verticalMerge: mergeValue } : {}) + }; +} + +/** Folds `w:vMerge` continuations into the `rowSpan` of the cell that started the merge. */ +function collapseVerticalMerges(rows: DocxTableCellDraft[][]): void { + const open: Array = []; + for (const row of rows) { + let column = 0; + const kept: DocxTableCellDraft[] = []; + for (const cell of row) { + const merge = cell.verticalMerge; + delete cell.verticalMerge; + if (merge === "continue" && open[column]) { + open[column]!.rowSpan += 1; + column += cell.colSpan; + continue; + } + for (let offset = 0; offset < cell.colSpan; offset += 1) { + open[column + offset] = merge === "restart" ? cell : null; + } + kept.push(cell); + column += cell.colSpan; + } + row.splice(0, row.length, ...kept); + } +} + +/** Direct children in the WordprocessingML namespace, whatever their local name. */ +function wordChildren(parent: Element): Element[] { + return Array.from(parent.childNodes).filter((node): node is Element => ( + node.nodeType === 1 && (node as Element).namespaceURI === WORD_NS + )); +} + +const EMPTY_DOCX_STYLES: DocxStyleSheet = { defaults: {}, styles: new Map() }; + +/** Loads document defaults and paragraph styles, including `w:basedOn` links. */ +async function readDocxStyles(zip: JSZip): Promise { + const xml = await zip.file("word/styles.xml")?.async("string"); + if (!xml) return EMPTY_DOCX_STYLES; + const document = parseXml(xml); + const defaults = directChildren( + directChildren(descendants(document, WORD_NS, "docDefaults")[0] ?? null, WORD_NS, "pPrDefault")[0] ?? null, + WORD_NS, + "pPr" + )[0]; + const styles = new Map(); + for (const style of descendants(document, WORD_NS, "style")) { + const styleId = namespacedAttribute(style, WORD_NS, "styleId", "w"); + if (!styleId) continue; + const basedOnElement = directChildren(style, WORD_NS, "basedOn")[0]; + styles.set(styleId, { + basedOn: basedOnElement ? namespacedAttribute(basedOnElement, WORD_NS, "val", "w") : null, + format: readDocxParagraphProperties(directChildren(style, WORD_NS, "pPr")[0]) + }); + } + return { + defaults: readDocxParagraphProperties(defaults), + styles + }; +} + +/** + * Merges document defaults, the paragraph style chain, and direct `w:pPr`. + * + * @returns the properties the paragraph actually sets; returns undefined when nothing is set. + */ +function resolveDocxParagraphFormat(paragraph: Element, styles: DocxStyleSheet): DocxParagraphFormat | undefined { + const properties = directChildren(paragraph, WORD_NS, "pPr")[0]; + const styleElement = directChildren(properties ?? null, WORD_NS, "pStyle")[0]; + const styleId = styleElement ? namespacedAttribute(styleElement, WORD_NS, "val", "w") : null; + const chain: DocxParagraphFormat[] = []; + const seen = new Set(); + let current = styleId; + while (current && !seen.has(current)) { + seen.add(current); + const style = styles.styles.get(current); + if (!style) break; + chain.push(style.format); + current = style.basedOn; + } + const format: DocxParagraphFormat = { + ...styles.defaults, + ...chain.reverse().reduce((merged, item) => ({ ...merged, ...item }), {}), + ...readDocxParagraphProperties(properties) + }; + return Object.keys(format).length ? format : undefined; +} + +/** Reads indent, spacing and alignment from one `w:pPr`. Absent attributes are left unset so a style can inherit them. */ +function readDocxParagraphProperties(properties: Element | undefined): DocxParagraphFormat { + if (!properties) return {}; + const format: DocxParagraphFormat = {}; + const indent = directChildren(properties, WORD_NS, "ind")[0]; + const spacing = directChildren(properties, WORD_NS, "spacing")[0]; + const justification = directChildren(properties, WORD_NS, "jc")[0]; + const alignment = justification + ? readDocxAlignment(namespacedAttribute(justification, WORD_NS, "val", "w")) + : null; + if (alignment) format.align = alignment; + assignLength(format, "indentLeft", readOptionalTwip(indent, "left") ?? readOptionalTwip(indent, "start")); + assignLength(format, "indentRight", readOptionalTwip(indent, "right") ?? readOptionalTwip(indent, "end")); + assignLength(format, "indentLeftChars", readOptionalTwip(indent, "leftChars") ?? readOptionalTwip(indent, "startChars")); + assignLength(format, "indentRightChars", readOptionalTwip(indent, "rightChars") ?? readOptionalTwip(indent, "endChars")); + const firstLine = readOptionalTwip(indent, "firstLine"); + const hanging = readOptionalTwip(indent, "hanging"); + assignLength(format, "firstLine", firstLine ?? (hanging == null ? null : -hanging)); + const firstLineChars = readOptionalTwip(indent, "firstLineChars"); + const hangingChars = readOptionalTwip(indent, "hangingChars"); + assignLength(format, "firstLineChars", firstLineChars ?? (hangingChars == null ? null : -hangingChars)); + assignLength(format, "before", readOptionalTwip(spacing, "before")); + assignLength(format, "after", readOptionalTwip(spacing, "after")); + assignLength(format, "beforeLines", readOptionalTwip(spacing, "beforeLines")); + assignLength(format, "afterLines", readOptionalTwip(spacing, "afterLines")); + assignLength(format, "line", readOptionalTwip(spacing, "line")); + const lineRule = spacing ? namespacedAttribute(spacing, WORD_NS, "lineRule", "w") : null; + if (lineRule === "auto" || lineRule === "exact" || lineRule === "atLeast") format.lineRule = lineRule; + return format; +} + +/** + * Converts a Word line-spacing value into a CSS line-height that cannot collapse lines. + * + * `auto` values are 240ths of a line. A value such as 14 is not a usable multiple: + * applying it as `14/240` paints every following line on top of the previous one. + * Exact spacing tighter than the text is raised to at least one line. + * + * @param line the `w:line` value. + * @param lineRule the `w:lineRule` value. + * @returns a CSS line-height, or undefined when the document does not set one. + */ +export function docxLineHeightCss(line: number, lineRule: DocxParagraphFormat["lineRule"]): number | string | undefined { + if (!(line > 0)) return undefined; + if (!lineRule || lineRule === "auto") return Math.max(line / 240, 1); + return `max(${line / 20}pt, 1.15em)`; +} + +function assignLength( + target: DocxParagraphFormat, + key: "indentLeft" | "indentRight" | "indentLeftChars" | "indentRightChars" | "firstLine" | "firstLineChars" | "before" | "after" | "beforeLines" | "afterLines" | "line", + value: number | null +): void { + if (value != null) target[key] = value; +} + +function readDocxAlignment(value: string | null): DocxParagraphFormat["align"] | null { + if (value === "center") return "center"; + if (value === "right" || value === "end") return "right"; + if (value === "both" || value === "distribute" || value === "thaiDistribute") return "justify"; + if (value === "left" || value === "start") return "left"; + return null; +} + +function readOptionalTwip(element: Element | undefined, localName: string): number | null { + if (!element) return null; + const raw = namespacedAttribute(element, WORD_NS, localName, "w"); + if (raw == null || raw.trim() === "") return null; + const value = Number(raw); + return Number.isFinite(value) ? value : null; +} + /** * Extracts every worksheet's cell values from an `.xlsx` archive. * @@ -102,26 +364,355 @@ export async function readXlsxSheets(data: ArrayBuffer | Blob): Promise): DocxBlock[] { + const format = resolveDocxParagraphFormat(paragraph, styles); + const headingLevel = readDocxHeadingLevel(paragraph); + const blocks: DocxBlock[] = []; const spans: DocxTextSpan[] = []; - // Descendants, not direct children: runs also live inside `w:hyperlink` and revision marks. + let usedHeading = false; + const flush = () => { + if (!spans.length) return; + blocks.push({ + kind: "paragraph", + headingLevel: usedHeading ? null : headingLevel, + spans: spans.splice(0, spans.length), + ...(format ? { format } : {}) + }); + usedHeading = true; + }; for (const run of descendants(paragraph, WORD_NS, "r")) { - const properties = directChildren(run, WORD_NS, "rPr")[0]; - const bold = Boolean(properties && isToggleOn(directChildren(properties, WORD_NS, "b")[0])); - const italic = Boolean(properties && isToggleOn(directChildren(properties, WORD_NS, "i")[0])); - let text = ""; - for (const node of Array.from(run.childNodes)) { + if (runIsInsideDrawing(run, paragraph)) continue; + const figures = readDocxRunFigures(run, images); + if (figures.length) { + flush(); + blocks.push(...figures); + continue; + } + spans.push(...readDocxRunSpans(run)); + } + flush(); + if (blocks.length) return blocks; + return [{ + kind: "paragraph", + headingLevel, + spans: [], + ...(format ? { format } : {}) + }]; +} + +/** A run inside a text box belongs to the diagram, not to the paragraph around it. */ +function runIsInsideDrawing(run: Element, paragraph: Element): boolean { + let parent = run.parentElement; + while (parent && parent !== paragraph) { + if (DRAWING_ANCESTORS.has(parent.localName ?? "")) return true; + parent = parent.parentElement; + } + return false; +} + +function readDocxRunSpans(run: Element): DocxTextSpan[] { + const properties = directChildren(run, WORD_NS, "rPr")[0]; + const bold = Boolean(properties && isToggleOn(directChildren(properties, WORD_NS, "b")[0])); + const italic = Boolean(properties && isToggleOn(directChildren(properties, WORD_NS, "i")[0])); + let text = ""; + for (const node of Array.from(run.childNodes)) { + if (node.nodeType !== 1) continue; + const element = node as Element; + if (element.namespaceURI !== WORD_NS) continue; + if (element.localName === "t") text += element.textContent ?? ""; + else if (element.localName === "tab") text += "\t"; + else if (element.localName === "br" || element.localName === "cr") text += "\n"; + } + return text ? [{ text, bold, italic }] : []; +} + +function readDocxRunFigures(run: Element, images: ReadonlyMap): DocxBlock[] { + const alternates = elementsNamed(run, "AlternateContent"); + if (alternates.length) { + const blocks: DocxBlock[] = []; + for (const alternate of alternates) { + const fallback = elementsNamed(alternate, "Fallback")[0]; + const groups = fallback ? topVmlGroups(fallback) : []; + if (groups.length) { + for (const group of groups) { + const drawing = readVmlGroup(group); + if (drawing) blocks.push(drawing); + } + } else { + const choice = elementsNamed(alternate, "Choice")[0]; + blocks.push(...readDocxBlips(choice ?? alternate, images)); + } + } + return blocks; + } + const blocks: DocxBlock[] = []; + for (const group of topVmlGroups(run)) { + const drawing = readVmlGroup(group); + if (drawing) blocks.push(drawing); + } + blocks.push(...readDocxBlips(run, images)); + return blocks; +} + +/** Resolves `r:embed` images to data URLs. Vector-only parts such as EMF are skipped. */ +async function readDocxImages(zip: JSZip): Promise> { + const xml = await zip.file("word/_rels/document.xml.rels")?.async("string"); + const images = new Map(); + if (!xml) return images; + for (const relationship of descendants(parseXml(xml), RELS_NS, "Relationship")) { + const id = relationship.getAttribute("Id"); + const type = relationship.getAttribute("Type") ?? ""; + const target = relationship.getAttribute("Target"); + if (!id || !target || !type.endsWith("/image")) continue; + const mime = imageMime(target); + if (!mime) continue; + const path = target.startsWith("/") ? target.slice(1) : `word/${target.replace(/^\.\//, "")}`; + const base64 = await zip.file(decodeXmlPath(path))?.async("base64"); + if (!base64) continue; + images.set(id, `data:${mime};base64,${base64}`); + } + return images; +} + +function imageMime(path: string): string | null { + const extension = path.split(".").pop()?.toLowerCase(); + if (extension === "png") return "image/png"; + if (extension === "jpg" || extension === "jpeg") return "image/jpeg"; + if (extension === "gif") return "image/gif"; + if (extension === "webp") return "image/webp"; + if (extension === "bmp") return "image/bmp"; + return null; +} + +function decodeXmlPath(path: string): string { + return path.replace(/&/g, "&"); +} + +function readDocxBlips(root: Element, images: ReadonlyMap): DocxBlock[] { + const blocks: DocxBlock[] = []; + for (const blip of elementsNamed(root, "blip")) { + const id = namespacedAttribute(blip, OFFICE_RELS_NS, "embed", "r"); + const src = id ? images.get(id) : undefined; + if (!src) continue; + const extent = nearestExtent(blip); + const widthPt = extent ? Number(extent.getAttribute("cx")) / EMU_PER_PT : 0; + const heightPt = extent ? Number(extent.getAttribute("cy")) / EMU_PER_PT : 0; + blocks.push({ + kind: "image", + src, + widthPt: widthPt > 0 ? widthPt : 360, + heightPt: heightPt > 0 ? heightPt : 240 + }); + } + return blocks; +} + +/** The display size lives on the wrapping `wp:extent`, not on the blip itself. */ +function nearestExtent(blip: Element): Element | null { + let parent = blip.parentElement; + while (parent) { + if (parent.localName === "inline" || parent.localName === "anchor") { + const extent = Array.from(parent.childNodes).find((node): node is Element => ( + node.nodeType === 1 && (node as Element).localName === "extent" + )); + if (extent) return extent; + } + parent = parent.parentElement; + } + return null; +} + +function readVmlGroup(group: Element): Extract | null { + const style = parseVmlStyle(group.getAttribute("style")); + const widthPt = parseFloat(style.width ?? ""); + const heightPt = parseFloat(style.height ?? ""); + const coord = parseCoordSize(group.getAttribute("coordsize")); + if (!(widthPt > 0) || !(heightPt > 0) || !coord) return null; + const shapes: DocxDrawingShape[] = []; + collectVmlShapes(group, coord.width, coord.height, 0, 0, 1, 1, shapes); + return shapes.length ? { kind: "drawing", widthPt, heightPt, shapes } : null; +} + +function parseCoordSize(raw: string | null): { width: number; height: number } | null { + const [widthText, heightText] = (raw ?? "").split(","); + const width = Number((widthText ?? "").trim()); + const height = Number((heightText ?? "").trim()); + if (!(width > 0) || !(height > 0)) return null; + return { width, height }; +} + +/** + * Flattens a VML group, including a group nested inside it, into one canvas. + * + * `ox`/`oy`/`sx`/`sy` are the current group's rectangle as fractions of that canvas. + */ +function collectVmlShapes( + group: Element, + coordW: number, + coordH: number, + ox: number, + oy: number, + sx: number, + sy: number, + shapes: DocxDrawingShape[] +): void { + for (const child of Array.from(group.childNodes)) { + if (child.nodeType !== 1) continue; + const element = child as Element; + if (element.namespaceURI !== VML_NS) continue; + const box = vmlBox(element); + if (!box || !(coordW > 0) || !(coordH > 0)) continue; + const x = ox + (box.left / coordW) * sx; + const y = oy + (box.top / coordH) * sy; + const w = (box.width / coordW) * sx; + const h = (box.height / coordH) * sy; + if (element.localName === "group") { + const childCoord = parseCoordSize(element.getAttribute("coordsize")); + collectVmlShapes( + element, + childCoord?.width ?? box.width, + childCoord?.height ?? box.height, + x, + y, + w, + h, + shapes + ); + continue; + } + const shape = readVmlShape(element, x, y, w, h); + if (shape) shapes.push(shape); + } +} + +function readVmlShape(element: Element, x: number, y: number, w: number, h: number): DocxDrawingShape | null { + if (!(w > 0) || !(h > 0)) return null; + const filled = element.getAttribute("filled") !== "f"; + const stroked = element.getAttribute("stroked") !== "f"; + const text = vmlText(element); + const spt = namespacedAttribute(element, OFFICE_VML_NS, "spt", "o"); + const role = vmlRole(element.localName ?? "", spt, text, stroked); + if (!role) return null; + if (!filled && !stroked && !text) return null; + if (!text && w > 0.9 && h > 0.9 && x < 0.05 && y < 0.05) return null; + const style = parseVmlStyle(element.getAttribute("style")); + const flip = style.flip ?? ""; + const stroke = Array.from(element.childNodes).find((node): node is Element => ( + node.nodeType === 1 && (node as Element).namespaceURI === VML_NS && (node as Element).localName === "stroke" + )); + return { + left: x * 100, + top: y * 100, + width: w * 100, + height: h * 100, + role, + text, + startArrow: isArrow(stroke?.getAttribute("startarrow")), + endArrow: isArrow(stroke?.getAttribute("endarrow")), + flipX: flip.split(/\s+/).includes("x"), + flipY: flip.split(/\s+/).includes("y"), + rotation: parseVmlRotation(style.rotation), + adj: parseVmlAdj(element.getAttribute("adj")) + }; +} + +function vmlRole(localName: string, spt: string | null, text: string, stroked: boolean): DocxDrawingShape["role"] | null { + if (localName === "roundrect" || spt === "2") return "round"; + if (spt === "4" || spt === "110") return "diamond"; + if (spt === "69") return "arrow-h"; + if (spt === "70") return "arrow-v"; + if (spt === "32") return "line"; + if (spt === "34" || spt === "35") return "elbow"; + if (localName === "rect" || spt === "1" || spt === "202") return stroked || !text ? "box" : "label"; + if (text) return stroked ? "box" : "label"; + return null; +} + +function vmlText(element: Element): string { + const lines: string[] = []; + for (const textbox of elementsNamed(element, "textbox")) { + if (textbox.parentElement !== element) continue; + for (const paragraph of descendants(textbox, WORD_NS, "p")) { + const line = descendants(paragraph, WORD_NS, "t").map((part) => part.textContent ?? "").join(""); + if (line) lines.push(line); + } + } + return lines.join("\n"); +} + +function vmlBox(element: Element): { left: number; top: number; width: number; height: number } | null { + const style = parseVmlStyle(element.getAttribute("style")); + const left = Number(style.left ?? ""); + const top = Number(style.top ?? ""); + const width = Number(style.width ?? ""); + const height = Number(style.height ?? ""); + if (![left, top, width, height].every(Number.isFinite)) return null; + return { left, top, width, height }; +} + +function parseVmlStyle(style: string | null): Record { + const result: Record = {}; + if (!style) return result; + for (const part of style.split(";")) { + const index = part.indexOf(":"); + if (index <= 0) continue; + result[part.slice(0, index).trim().toLowerCase()] = part.slice(index + 1).trim(); + } + return result; +} + +/** A trailing `f` means the angle is in 65536ths of a degree. */ +function parseVmlRotation(raw: string | undefined): number { + if (!raw) return 0; + const fixed = /fd?$/.test(raw); + const value = Number(fixed ? raw.replace(/fd?$/, "") : raw); + if (!Number.isFinite(value)) return 0; + return fixed ? value / 65536 : value; +} + +/** Connector adjustments are in the shape coordinate space, usually 21600 across. */ +function parseVmlAdj(raw: string | null): number { + if (!raw) return 0.5; + const value = Number(raw.split(",")[0]); + if (!Number.isFinite(value)) return 0.5; + return Math.abs(value) > 21600 ? value / 100000 : value / 21600; +} + +function isArrow(value: string | null | undefined): boolean { + return Boolean(value && value !== "none"); +} + +function topVmlGroups(root: Element): Element[] { + return elementsNamed(root, "group").filter((group) => { + if (group.namespaceURI !== VML_NS) return false; + let parent = group.parentElement; + while (parent && parent !== root) { + if (parent.namespaceURI === VML_NS && parent.localName === "group") return false; + parent = parent.parentElement; + } + return true; + }); +} + +/** Descendants with this local name in any namespace. */ +function elementsNamed(root: Element, localName: string): Element[] { + const matches: Element[] = []; + const visit = (parent: Element) => { + for (const node of Array.from(parent.childNodes)) { if (node.nodeType !== 1) continue; const element = node as Element; - if (element.namespaceURI !== WORD_NS) continue; - if (element.localName === "t") text += element.textContent ?? ""; - else if (element.localName === "tab") text += "\t"; - else if (element.localName === "br" || element.localName === "cr") text += "\n"; + if (element.localName === localName) matches.push(element); + visit(element); } - if (text) spans.push({ text, bold, italic }); - } - return spans; + }; + visit(root); + return matches; } /** diff --git a/App/frontend/desktop/src/lib/tests/office-preview.test.ts b/App/frontend/desktop/src/lib/tests/office-preview.test.ts index 8d86668e6..48dcbaedd 100644 --- a/App/frontend/desktop/src/lib/tests/office-preview.test.ts +++ b/App/frontend/desktop/src/lib/tests/office-preview.test.ts @@ -3,7 +3,7 @@ /** OOXML preview parser tests. */ import JSZip from "jszip"; import { describe, expect, it } from "vitest"; -import { readDocxBlocks, readXlsxSheets } from "../office-preview.js"; +import { docxLineHeightCss, readDocxBlocks, readXlsxSheets, type DocxBlock, type DocxTableCell } from "../office-preview.js"; const WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; const SHEET_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; @@ -41,9 +41,10 @@ describe("docx preview parser", () => { expect(blocks[3]).toEqual({ kind: "paragraph", headingLevel: null, spans: [] }); expect(blocks[4]).toEqual({ kind: "table", + columnWidths: [], rows: [ - [[{ text: "板块", bold: false, italic: false }], [{ text: "判定", bold: false, italic: false }]], - [[{ text: "社保", bold: false, italic: false }], [{ text: "不合规", bold: false, italic: false }]] + [cell("板块"), cell("判定")], + [cell("社保"), cell("不合规")] ] }); }); @@ -65,7 +66,172 @@ describe("docx preview parser", () => { `)); const table = blocks[0] as Extract; - expect(table.rows[0]?.[0]).toEqual([{ text: "外层", bold: false, italic: false }]); + expect(table.rows[0]?.[0]?.content).toEqual([ + paragraph("外层"), + { + kind: "table", + columnWidths: [], + rows: [[cell("内层")]] + } + ]); + }); + + it("keeps content controls, merged cells, column widths, and separate paragraphs", async () => { + const blocks = await readDocxBlocks(await buildDocx(` + 控件内 + + + + 标题副题 + + + 合并 + 右上 + + + 被并入 + 右下 + + + `)); + + expect(blocks[0]).toEqual(paragraph("控件内")); + const table = blocks[1] as Extract; + expect(table.columnWidths).toEqual([2000, 4000]); + expect(table.rows[0]).toEqual([cell("标题", { colSpan: 2, content: [paragraph("标题"), paragraph("副题")] })]); + expect(table.rows[1]?.[0]).toMatchObject({ rowSpan: 2, content: [paragraph("合并")] }); + expect(table.rows[1]?.map((item) => item.content[0])).toEqual([paragraph("合并"), paragraph("右上")]); + expect(table.rows[2]).toEqual([cell("右下")]); + }); + + it("applies direct paragraph metrics and inherits style spacing", async () => { + const blocks = await readDocxBlocks(await buildDocx(` + + + + + + + 直接 + + + + 样式 + + + + + + + + 悬挂 + + `, ` + + + + + + + + + + + `)); + + expect(blocks[0]).toMatchObject({ + format: { + align: "right", + indentLeft: 720, + firstLineChars: 200, + before: 200, + after: 80, + line: 276, + lineRule: "exact" + } + }); + expect(blocks[1]).toMatchObject({ + format: { + align: "center", + indentLeft: 420, + after: 120, + line: 360, + lineRule: "auto" + } + }); + expect(docxLineHeightCss(14, "auto")).toBe(1); + expect(docxLineHeightCss(360, "auto")).toBe(1.5); + expect(docxLineHeightCss(320, "exact")).toBe("max(16pt, 1.15em)"); + expect(blocks[2]).toMatchObject({ + format: { + align: "center", + indentLeft: 420, + firstLine: -360, + beforeLines: 100, + after: 0, + line: 360, + lineRule: "auto" + } + }); + }); + + it("draws embedded images and positioned diagrams without flattening their text", async () => { + const png = Uint8Array.from(atob( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + ), (char) => char.charCodeAt(0)); + const blocks = await readDocxBlocks(await buildDocx(` + + + + + + + + + + 图注 + + + + + + + 不要重复 + + + + + + + 工控计算机 + + + + + + + + + + + `, undefined, { + "word/_rels/document.xml.rels": ``, + "word/media/image1.png": png + })); + + expect(blocks[0]).toMatchObject({ kind: "image", widthPt: 100, heightPt: 50 }); + expect((blocks[0] as Extract).src.startsWith("data:image/png;base64,")).toBe(true); + expect(blocks[1]).toMatchObject({ kind: "paragraph", spans: [{ text: "图注" }] }); + expect(blocks[2]).toMatchObject({ + kind: "drawing", + widthPt: 200, + heightPt: 100, + shapes: [ + { role: "box", text: "工控计算机", left: 5, top: 10, width: 30, height: 30 }, + { role: "line", text: "", endArrow: true, left: 40, top: 20, width: 20, height: 2 } + ] + }); + expect(JSON.stringify(blocks)).not.toContain("不要重复"); }); it("rejects an archive that is not a Word document", async () => { @@ -155,9 +321,26 @@ describe("xlsx preview parser", () => { }); }); -async function buildDocx(body: string): Promise { +function paragraph(text: string): Extract { + return { kind: "paragraph", headingLevel: null, spans: [{ text, bold: false, italic: false }] }; +} + +function cell(text: string, extra?: Partial): DocxTableCell { + return { + content: [paragraph(text)], + colSpan: 1, + rowSpan: 1, + ...extra + }; +} + +async function buildDocx(body: string, styles?: string, files?: Record): Promise { const zip = new JSZip(); zip.file("word/document.xml", `${body}`); + if (styles) { + zip.file("word/styles.xml", `${styles}`); + } + for (const [path, contents] of Object.entries(files ?? {})) zip.file(path, contents); return zip.generateAsync({ type: "arraybuffer" }); } diff --git a/App/frontend/desktop/src/pages/file-preview/file-preview.tsx b/App/frontend/desktop/src/pages/file-preview/file-preview.tsx index 3a3443472..6df661d1f 100644 --- a/App/frontend/desktop/src/pages/file-preview/file-preview.tsx +++ b/App/frontend/desktop/src/pages/file-preview/file-preview.tsx @@ -3,10 +3,12 @@ import { lazy, useCallback, useEffect, + useId, useMemo, useRef, useState, type ComponentPropsWithoutRef, + type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type RefObject @@ -31,9 +33,12 @@ import remarkGfm from "remark-gfm"; import { useTranslation } from "../../i18n/use-translation.js"; import { resolveFileType } from "../../lib/file-type.js"; import { + docxLineHeightCss, readDocxBlocks, readXlsxSheets, type DocxBlock, + type DocxDrawingShape, + type DocxParagraphFormat, type DocxTextSpan, type XlsxSheet } from "../../lib/office-preview.js"; @@ -690,20 +695,177 @@ function OfficePreview(props: { kind: "docx" | "xlsx"; blob: Blob }) { function DocxPreview(props: { blocks: DocxBlock[] }) { return (
- {props.blocks.map((block, index) => { - if (block.kind === "table") { - return {block.rows.map((row, rowIndex) => {row.map((cell, cellIndex) => )})}
{cell.map((span, spanIndex) => )}
; - } - if (!block.spans.length) return
; - const content = block.spans.map((span, spanIndex) => ); - if (block.headingLevel === null) return

{content}

; - const Heading = `h${Math.min(6, block.headingLevel + 1)}` as "h2"; - return {content}; - })} +
); } +function DocxBlocks(props: { blocks: DocxBlock[] }) { + return props.blocks.map((block, index) => { + if (block.kind === "table") return ; + if (block.kind === "image") return ; + if (block.kind === "drawing") return ; + if (!block.spans.length && !block.format) return
; + const content = block.spans.length + ? block.spans.map((span, spanIndex) => ) + : "\u00a0"; + const style = docxParagraphStyle(block.format); + if (block.headingLevel === null) return

{content}

; + const Heading = `h${Math.min(6, block.headingLevel + 1)}` as "h2"; + return {content}; + }); +} + +function docxParagraphStyle(format: DocxParagraphFormat | undefined): CSSProperties | undefined { + if (!format) return undefined; + const style: CSSProperties = {}; + if (format.align) style.textAlign = format.align; + const left = characterOrTwip(format.indentLeftChars, format.indentLeft); + const right = characterOrTwip(format.indentRightChars, format.indentRight); + const firstLine = characterOrTwip(format.firstLineChars, format.firstLine); + const before = characterOrTwip(format.beforeLines, format.before); + const after = characterOrTwip(format.afterLines, format.after); + if (left) style.paddingLeft = left; + if (right) style.paddingRight = right; + if (firstLine) style.textIndent = firstLine; + if (before) style.marginTop = before; + if (after) style.marginBottom = after; + if (format.line != null) { + const lineHeight = docxLineHeightCss(format.line, format.lineRule); + if (lineHeight != null) style.lineHeight = lineHeight; + } + return Object.keys(style).length ? style : undefined; +} + +/** Character measurements are hundredths of an em. Twips are twentieths of a point. */ +function characterOrTwip(characters: number | undefined, twips: number | undefined): string | undefined { + if (characters != null && characters !== 0) return `${characters / 100}em`; + if (twips != null && twips !== 0) return `${twips / 20}pt`; + return undefined; +} + +function DocxImage(props: { block: Extract }) { + return ( + + ); +} + +function DocxDrawing(props: { block: Extract }) { + const markerId = `office-arrow-${useId().replace(/:/g, "")}`; + const { widthPt, heightPt, shapes } = props.block; + const connectors = shapes.filter((shape) => shape.role === "line" || shape.role === "elbow" || shape.role === "arrow-h" || shape.role === "arrow-v"); + const boxes = shapes.filter((shape) => !connectors.includes(shape)); + return ( +
+ + + + + + + + + + {connectors.map((shape, index) => ( + + ))} + + {boxes.map((shape, index) => )} +
+ ); +} + +function DocxConnector(props: { shape: DocxDrawingShape; widthPt: number; heightPt: number; markerId: string }) { + const points = connectorPoints(props.shape).map(([u, v]) => mapDrawingPoint(props.shape, u, v, props.widthPt, props.heightPt)); + const common = { + fill: props.shape.role === "arrow-h" || props.shape.role === "arrow-v" ? "#fff" : "none", + stroke: "#222", + strokeWidth: 1.1, + strokeLinejoin: "round" as const, + markerEnd: props.shape.endArrow ? `url(#${props.markerId})` : undefined, + markerStart: props.shape.startArrow ? `url(#${props.markerId}-start)` : undefined + }; + if (props.shape.role === "arrow-h" || props.shape.role === "arrow-v") { + return `${x},${y}`).join(" ")} {...common} />; + } + return `${x},${y}`).join(" ")} {...common} />; +} + +function DocxDrawingBox(props: { shape: DocxDrawingShape }) { + const { shape } = props; + return ( +
+ {shape.role === "diamond" ? ( + + ) : null} + {shape.text ? {shape.text} : null} +
+ ); +} + +/** Unit-box points of a connector, before its flip and rotation. */ +function connectorPoints(shape: DocxDrawingShape): Array<[number, number]> { + if (shape.role === "arrow-h") { + return [[0, 0.5], [0.22, 0.16], [0.22, 0.38], [0.78, 0.38], [0.78, 0.16], [1, 0.5], [0.78, 0.84], [0.78, 0.62], [0.22, 0.62], [0.22, 0.84]]; + } + if (shape.role === "arrow-v") { + return [[0.5, 0], [0.84, 0.22], [0.62, 0.22], [0.62, 0.78], [0.84, 0.78], [0.5, 1], [0.16, 0.78], [0.38, 0.78], [0.38, 0.22], [0.16, 0.22]]; + } + if (shape.role === "elbow") return [[0, 0], [shape.adj, 0], [shape.adj, 1], [1, 1]]; + return [[0, 0], [1, 1]]; +} + +function mapDrawingPoint(shape: DocxDrawingShape, u: number, v: number, widthPt: number, heightPt: number): [number, number] { + const x = shape.flipX ? 1 - u : u; + const y = shape.flipY ? 1 - v : v; + const boxW = shape.width / 100 * widthPt; + const boxH = shape.height / 100 * heightPt; + const cx = shape.left / 100 * widthPt + boxW / 2; + const cy = shape.top / 100 * heightPt + boxH / 2; + const dx = (x - 0.5) * boxW; + const dy = (y - 0.5) * boxH; + const rad = shape.rotation * Math.PI / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos]; +} + +function DocxTable(props: { block: Extract }) { + const widths = props.block.columnWidths.some((width) => width > 0) ? props.block.columnWidths : []; + return ( +
+ + {widths.length ? ( + + {widths.map((width, index) => )} + + ) : null} + + {props.block.rows.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
1 ? cell.colSpan : undefined} rowSpan={cell.rowSpan > 1 ? cell.rowSpan : undefined}> + +
+
+ ); +} + function DocxSpan(props: { span: DocxTextSpan }) { if (!props.span.bold && !props.span.italic) return <>{props.span.text}; return {props.span.text}; diff --git a/App/frontend/desktop/src/pages/file-preview/pdf-preview.tsx b/App/frontend/desktop/src/pages/file-preview/pdf-preview.tsx index 966ab14e3..e1e53c7d1 100644 --- a/App/frontend/desktop/src/pages/file-preview/pdf-preview.tsx +++ b/App/frontend/desktop/src/pages/file-preview/pdf-preview.tsx @@ -19,17 +19,22 @@ import { } from "lucide-react"; import { GlobalWorkerOptions, + PDFWorker, TextLayer, getDocument, type PDFDocumentProxy, type PDFPageProxy } from "pdfjs-dist"; +import PdfJsWorker from "pdfjs-dist/build/pdf.worker.min.mjs?worker"; import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url"; import "pdfjs-dist/web/pdf_viewer.css"; import { useTranslation } from "../../i18n/use-translation.js"; import { computePdfDisplayScale, highlightPdfTextLayer, nextPdfMatchIndex } from "./pdf-preview-state.js"; import type { FilePreviewViewState } from "./file-preview-types.js"; +// Fallback if the dedicated worker fails to boot. The primary path below +// constructs the worker from the bundled module, so a packaged server that +// labels `.mjs` as a download cannot block PDF rendering. GlobalWorkerOptions.workerSrc = pdfWorkerUrl; const MIN_SCALE = 0.5; @@ -75,7 +80,12 @@ export function PdfPreview(props: PdfPreviewProps): ReactNode { useEffect(() => { let active = true; - const loadingTask = getDocument({ data: new Uint8Array(props.data.slice(0)) }); + const port = new PdfJsWorker(); + const worker = PDFWorker.create({ port }); + const loadingTask = getDocument({ + data: new Uint8Array(props.data.slice(0)), + worker + }); setDocument(null); setFailed(false); void loadingTask.promise.then((next) => { @@ -87,6 +97,7 @@ export function PdfPreview(props: PdfPreviewProps): ReactNode { return () => { active = false; void loadingTask.destroy(); + worker.destroy(); }; }, [props.data]); diff --git a/App/frontend/desktop/src/styles.css b/App/frontend/desktop/src/styles.css index f92ee9e6d..a46bc2bce 100644 --- a/App/frontend/desktop/src/styles.css +++ b/App/frontend/desktop/src/styles.css @@ -13666,9 +13666,16 @@ body.memmy-platform-windows:not(.memmy-window-fullscreen) .memmy-notification-st padding: 24px clamp(20px, 6%, 52px) 48px; color: color-mix(in srgb, var(--color-text-ink) 86%, transparent); font-size: 14px; +} + +.file-preview__markdown { line-height: 1.7; } +.file-preview__office-document { + line-height: 1.15; +} + .file-preview__markdown > :first-child, .file-preview__office-document > :first-child { margin-top: 0; @@ -13693,11 +13700,14 @@ body.memmy-platform-windows:not(.memmy-window-fullscreen) .memmy-notification-st .file-preview__markdown p, .file-preview__markdown ul, -.file-preview__markdown ol, -.file-preview__office-document p { +.file-preview__markdown ol { margin: 0.75em 0; } +.file-preview__office-document p { + margin: 0; +} + .file-preview__markdown code { border-radius: 4px; padding: 0.12em 0.35em; @@ -13746,6 +13756,21 @@ body.memmy-platform-windows:not(.memmy-window-fullscreen) .memmy-notification-st font-size: 12px; } +.file-preview__office-document table { + width: max-content; + min-width: 100%; + table-layout: auto; + tab-size: 4; +} + +.file-preview__office-document td { + white-space: pre-wrap; +} + +.file-preview__office-document td p { + margin: 0; +} + .file-preview__markdown th, .file-preview__markdown td, .file-preview__office-document td, @@ -13761,6 +13786,82 @@ body.memmy-platform-windows:not(.memmy-window-fullscreen) .memmy-notification-st height: 12px; } +.file-preview__office-image { + display: block; + width: auto; + max-width: 100%; + height: auto; + margin: 0.8em auto; +} + +.file-preview__office-drawing { + position: relative; + container-type: inline-size; + margin: 0.9em auto; +} + +.file-preview__office-drawing-svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; +} + +.file-preview__office-drawing-svg marker path { + fill: #222; +} + +.file-preview__office-shape { + position: absolute; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + padding: 1px 3px; + background: #fff; + color: #222; + text-align: center; +} + +.file-preview__office-shape.is-box, +.file-preview__office-shape.is-round { + border: 1px solid #222; +} + +.file-preview__office-shape.is-round { + border-radius: 999px; +} + +.file-preview__office-shape.is-diamond, +.file-preview__office-shape.is-label { + background: transparent; + border: 0; +} + +.file-preview__office-diamond { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; +} + +.file-preview__office-diamond polygon { + fill: #fff; + stroke: #222; + stroke-width: 1.5; + vector-effect: non-scaling-stroke; +} + +.file-preview__office-shape-text { + position: relative; + z-index: 1; + font-size: clamp(10px, 2.3cqw, 14px); + line-height: 1.2; + white-space: pre-wrap; +} + .file-preview__spreadsheet { display: flex; flex-direction: column; diff --git a/App/shell/desktop/src/main/renderer-static-server.ts b/App/shell/desktop/src/main/renderer-static-server.ts index e6697e289..021ba8260 100644 --- a/App/shell/desktop/src/main/renderer-static-server.ts +++ b/App/shell/desktop/src/main/renderer-static-server.ts @@ -9,6 +9,9 @@ const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", ".ico": "image/x-icon", ".js": "text/javascript; charset=utf-8", + // pdf.js ships its renderer worker as .mjs. A module worker is rejected + // unless this response is a JavaScript MIME type, which fails every PDF preview. + ".mjs": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".map": "application/json; charset=utf-8", ".png": "image/png", diff --git a/App/shell/desktop/tests/renderer-static-server.test.ts b/App/shell/desktop/tests/renderer-static-server.test.ts index 3e904f7ea..077c6f755 100644 --- a/App/shell/desktop/tests/renderer-static-server.test.ts +++ b/App/shell/desktop/tests/renderer-static-server.test.ts @@ -15,6 +15,7 @@ describe("packaged renderer static server", () => { const root = await mkdtemp(join(tmpdir(), "memmy-renderer-static-")); await writeFile(join(root, "index.html"), "Memmy", "utf8"); await writeFile(join(root, "app.js"), "console.log('memmy');", "utf8"); + await writeFile(join(root, "pdf.worker.min.mjs"), "export default 1;", "utf8"); const server = await startPackagedRendererStaticServer({ rootDirectory: root, @@ -33,5 +34,9 @@ describe("packaged renderer static server", () => { const assetResponse = await fetch(`${server.baseUrl}/app.js`); expect(assetResponse.status).toBe(200); expect(await assetResponse.text()).toContain("memmy"); + + const workerResponse = await fetch(`${server.baseUrl}/pdf.worker.min.mjs`); + expect(workerResponse.status).toBe(200); + expect(workerResponse.headers.get("content-type")).toContain("text/javascript"); }); });