From 7b276e8f3d04796f18feb0c001c6be9783291099 Mon Sep 17 00:00:00 2001 From: Mariano Gabriel Date: Sun, 5 Jul 2026 11:26:45 -0300 Subject: [PATCH] viewers --- doocus/extractors/office.py | 34 ++- ui/doocus-app/package-lock.json | 237 +++++++++++++++++- ui/doocus-app/package.json | 4 +- ui/doocus-app/src/components/DocDetail.vue | 72 +----- ui/doocus-app/src/components/NativeViewer.vue | 112 +++++++++ ui/doocus-app/src/env.d.ts | 7 + 6 files changed, 402 insertions(+), 64 deletions(-) create mode 100644 ui/doocus-app/src/components/NativeViewer.vue diff --git a/doocus/extractors/office.py b/doocus/extractors/office.py index c90380d..12e5c43 100644 --- a/doocus/extractors/office.py +++ b/doocus/extractors/office.py @@ -11,9 +11,29 @@ from .base import ExtractionResult MAX_SHEET_ROWS = 200 +# Generic core-property titles Office writes by default — worse than useless as a +# display title (they hide the real one), so we drop them and derive from content. +PLACEHOLDER_TITLES = { + "word document", "powerpoint presentation", "excel workbook", + "microsoft word document", "microsoft powerpoint presentation", + "microsoft excel worksheet", "presentation", "workbook", "document", +} + + +def _good_title(t) -> bool: + return bool(t) and str(t).strip().lower() not in PLACEHOLDER_TITLES + + +def _first_line(text: str, limit: int = 120) -> str: + for ln in text.splitlines(): + s = ln.lstrip("#").strip() + if s: + return s[:limit] + return "" + def _core_props(props) -> dict: - """Common OOXML core properties → metadata (only non-empty ones). + """Common OOXML core properties → metadata (only non-empty, non-placeholder). python-docx/pptx expose `.author`/`.last_modified_by`; openpyxl uses `.creator`/`.lastModifiedBy`. Each target checks both spellings. @@ -31,6 +51,8 @@ def _core_props(props) -> dict: for src in sources: val = getattr(props, src, None) if val: + if dst == "title" and not _good_title(val): + continue # skip "Word Document" & friends; derive from content out[dst] = str(val) break return out @@ -43,6 +65,10 @@ def _extract_docx(source: Path) -> ExtractionResult: content = "\n\n".join(p for p in paras if p.strip()) + "\n" metadata = _core_props(doc.core_properties) metadata["paragraphs"] = len([p for p in paras if p.strip()]) + if "title" not in metadata: + t = _first_line(content) + if t: + metadata["title"] = t return ExtractionResult(content=content, metadata=metadata, extractor="office/python-docx@1") @@ -62,6 +88,12 @@ def _extract_pptx(source: Path) -> ExtractionResult: content = "\n\n".join(blocks) + "\n" metadata = _core_props(prs.core_properties) metadata["slides"] = len(prs.slides) + if "title" not in metadata and prs.slides: + # first slide's title placeholder, if any + for shape in prs.slides[0].shapes: + if shape.has_text_frame and shape.text_frame.text.strip(): + metadata["title"] = shape.text_frame.text.strip()[:120] + break return ExtractionResult(content=content, metadata=metadata, extractor="office/python-pptx@1") diff --git a/ui/doocus-app/package-lock.json b/ui/doocus-app/package-lock.json index fe141a6..f50f132 100644 --- a/ui/doocus-app/package-lock.json +++ b/ui/doocus-app/package-lock.json @@ -9,8 +9,10 @@ "version": "0.1.0", "dependencies": { "jszip": "^3.10", + "mammoth": "^1.8", "marked": "^14", - "vue": "^3.5" + "vue": "^3.5", + "xlsx": "^0.18" }, "devDependencies": { "@types/node": "^22", @@ -1060,6 +1062,24 @@ "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", "license": "MIT" }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/alien-signals": { "version": "1.0.13", "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", @@ -1067,6 +1087,15 @@ "dev": true, "license": "MIT" }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1074,6 +1103,32 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", @@ -1084,12 +1139,46 @@ "balanced-match": "^1.0.0" } }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1103,6 +1192,21 @@ "dev": true, "license": "MIT" }, + "node_modules/dingbat-to-unicode": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", + "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", + "license": "BSD-2-Clause" + }, + "node_modules/duck": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "license": "BSD", + "dependencies": { + "underscore": "^1.13.1" + } + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", @@ -1181,6 +1285,15 @@ } } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1245,6 +1358,17 @@ "immediate": "~3.0.5" } }, + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "license": "BSD-2-Clause", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1254,6 +1378,30 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mammoth": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz", + "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", + "license": "BSD-2-Clause", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "bluebird": "~3.4.0", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "path-is-absolute": "^1.0.0", + "underscore": "^1.13.1", + "xmlbuilder": "^10.0.0" + }, + "bin": { + "mammoth": "bin/mammoth" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/marked": { "version": "14.1.4", "resolved": "https://registry.npmjs.org/marked/-/marked-14.1.4.tgz", @@ -1307,6 +1455,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/option": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", + "license": "BSD-2-Clause" + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -1320,6 +1474,15 @@ "dev": true, "license": "MIT" }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1454,6 +1617,24 @@ "node": ">=0.10.0" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -1494,6 +1675,12 @@ "node": ">=14.17" } }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -1626,6 +1813,54 @@ "peerDependencies": { "typescript": ">=5.0.0" } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } } } } diff --git a/ui/doocus-app/package.json b/ui/doocus-app/package.json index 2f66166..81daccf 100644 --- a/ui/doocus-app/package.json +++ b/ui/doocus-app/package.json @@ -11,8 +11,10 @@ }, "dependencies": { "jszip": "^3.10", + "mammoth": "^1.8", "marked": "^14", - "vue": "^3.5" + "vue": "^3.5", + "xlsx": "^0.18" }, "devDependencies": { "@types/node": "^22", diff --git a/ui/doocus-app/src/components/DocDetail.vue b/ui/doocus-app/src/components/DocDetail.vue index 86e317f..07174a5 100644 --- a/ui/doocus-app/src/components/DocDetail.vue +++ b/ui/doocus-app/src/components/DocDetail.vue @@ -2,35 +2,18 @@ import { computed } from 'vue' import { marked } from 'marked' import SplitPane from '@framework/components/SplitPane.vue' +import NativeViewer from '@/components/NativeViewer.vue' import { store, humanBytes } from '@/store' const d = computed(() => store.detail) const node = computed(() => d.value?.node) -type ViewerKind = 'pdf' | 'image' | 'html' | 'markdown' | 'text' | 'office' | 'video' | 'download' +// Left pane (extracted search text) only exists for extracted heavy formats, +// and not for pptx (whose viewer already shows the per-slide text). +const hasExtracted = computed(() => + node.value?.mode === 'extracted' && node.value.ext !== 'pptx' && d.value?.content != null) -const viewerKind = computed(() => { - const ext = node.value?.ext ?? '' - if (ext === 'pdf') return 'pdf' - if (['png', 'jpg', 'jpeg'].includes(ext)) return 'image' - if (['html', 'htm'].includes(ext)) return 'html' - if (['md', 'markdown'].includes(ext)) return 'markdown' - if (['txt', 'json', 'yaml', 'yml', 'csv'].includes(ext)) return 'text' - if (['docx', 'pptx', 'xlsx'].includes(ext)) return 'office' - if (ext === 'mp4') return 'video' - return 'download' -}) - -// Left pane (extracted search text) only exists for extracted heavy formats. -const hasExtracted = computed(() => node.value?.mode === 'extracted' && d.value?.content != null) - -const extractedHtml = computed(() => - d.value?.content ? marked.parse(d.value.content) : '') - -// Right pane text (for text-native originals rendered inline). -const inlineText = computed(() => d.value?.text ?? '') -const inlineMarkdown = computed(() => - viewerKind.value === 'markdown' && inlineText.value ? marked.parse(inlineText.value) : '') +const extractedHtml = computed(() => (d.value?.content ? marked.parse(d.value.content) : '')) const metaRows = computed(() => { const m = d.value?.meta as Record | null @@ -54,11 +37,10 @@ const metaRows = computed(() => { {{ node.mode }} {{ node.path }} {{ node.ext }} · {{ humanBytes(node.bytes) }} - ↓ original - +