@open-file-viewer/core 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -123,6 +123,44 @@ pdfPlugin({
123
123
  This keeps compatibility at the cost of holding one extra copy of the PDF in memory, so use it only
124
124
  for affected environments.
125
125
 
126
+ ## High-Fidelity Office Conversion
127
+
128
+ Browser-side Office renderers cannot perfectly reproduce Word/WPS layout for files with anchored
129
+ textboxes, absolute positioning, custom fonts, headers/footers or legacy binary formats. For those
130
+ files, configure `officePlugin({ convert })` to send the file to your own LibreOffice, OnlyOffice or
131
+ Microsoft Graph conversion service and return a PDF. The converted PDF is rendered by the built-in
132
+ PDF viewer.
133
+
134
+ ```ts
135
+ officePlugin({
136
+ pdf: { workerSrc: pdfWorkerSrc },
137
+ async convert({ file, arrayBuffer, reason }) {
138
+ const form = new FormData();
139
+ form.append("file", new Blob([arrayBuffer]), file.name);
140
+ form.append("reason", reason);
141
+
142
+ const response = await fetch("/api/office/convert-to-pdf", {
143
+ method: "POST",
144
+ body: form
145
+ });
146
+
147
+ if (!response.ok) {
148
+ throw new Error("Office conversion failed");
149
+ }
150
+
151
+ return {
152
+ blob: await response.blob(),
153
+ fileName: file.name.replace(/\.[^.]+$/, ".pdf"),
154
+ mimeType: "application/pdf"
155
+ };
156
+ }
157
+ });
158
+ ```
159
+
160
+ Open File Viewer does not upload files by default. The conversion hook is only called when you
161
+ explicitly configure it, and currently targets `complex-docx` and `legacy-office` cases. You can also
162
+ return `{ url, fileName, mimeType: "application/pdf" }` when your service stores the converted PDF.
163
+
126
164
  ## CAD Customization
127
165
 
128
166
  `cadPlugin()` has two CAD preview layers:
package/dist/index.cjs CHANGED
@@ -735,7 +735,7 @@ function createViewer(options) {
735
735
  if (destroyed || token !== renderToken) {
736
736
  return;
737
737
  }
738
- currentInstance?.destroy();
738
+ destroyPreviewInstance(currentInstance);
739
739
  currentInstance = void 0;
740
740
  viewport.replaceChildren();
741
741
  setLoading(true);
@@ -757,7 +757,7 @@ function createViewer(options) {
757
757
  setError
758
758
  });
759
759
  if (destroyed || token !== renderToken) {
760
- nextInstance.destroy();
760
+ destroyPreviewInstance(nextInstance);
761
761
  return;
762
762
  }
763
763
  currentInstance = nextInstance;
@@ -814,7 +814,7 @@ function createViewer(options) {
814
814
  destroyed = true;
815
815
  renderToken += 1;
816
816
  resizeObserver.destroy();
817
- currentInstance?.destroy();
817
+ destroyPreviewInstance(currentInstance);
818
818
  toolbar?.destroy();
819
819
  theme.destroy();
820
820
  container.replaceChildren();
@@ -825,6 +825,16 @@ function createViewer(options) {
825
825
  }
826
826
  };
827
827
  }
828
+ function destroyPreviewInstance(instance) {
829
+ if (!instance) {
830
+ return;
831
+ }
832
+ try {
833
+ instance.destroy();
834
+ } catch (error) {
835
+ console.error("Failed to destroy file preview instance:", error);
836
+ }
837
+ }
828
838
  function normalizeQueue(options) {
829
839
  if (options.files && options.files.length > 0) {
830
840
  return options.files.map(
@@ -4628,7 +4638,7 @@ async function renderPdfDocumentPreview(options) {
4628
4638
  },
4629
4639
  destroy() {
4630
4640
  options.viewport.classList.remove("ofv-center");
4631
- documentTask?.destroy?.();
4641
+ destroyPdfResource(documentTask);
4632
4642
  if (options.revokeUrlOnDestroy) {
4633
4643
  revokeObjectUrl(options.fileUrl, Boolean(options.isExternal));
4634
4644
  }
@@ -4861,13 +4871,27 @@ async function renderPdfDocumentPreview(options) {
4861
4871
  }
4862
4872
  });
4863
4873
  pageStates.length = 0;
4864
- void pdfDocument.destroy?.();
4874
+ destroyPdfResource(pdfDocument);
4875
+ destroyPdfResource(documentTask);
4865
4876
  if (options.revokeUrlOnDestroy) {
4866
4877
  revokeObjectUrl(options.fileUrl, Boolean(options.isExternal));
4867
4878
  }
4868
4879
  }
4869
4880
  };
4870
4881
  }
4882
+ function destroyPdfResource(resource) {
4883
+ if (!resource || typeof resource !== "object") {
4884
+ return;
4885
+ }
4886
+ const candidate = resource;
4887
+ if (typeof candidate.destroy === "function") {
4888
+ void candidate.destroy();
4889
+ return;
4890
+ }
4891
+ if (typeof candidate.cleanup === "function") {
4892
+ void candidate.cleanup();
4893
+ }
4894
+ }
4871
4895
  function getPdfOutputScale() {
4872
4896
  if (typeof window === "undefined") {
4873
4897
  return 1;
@@ -5855,7 +5879,7 @@ var officeMimeFormatMap = {
5855
5879
  "application/vnd.oasis.opendocument.presentation-flat-xml": "fodp",
5856
5880
  "application/vnd.apple.keynote": "key"
5857
5881
  };
5858
- function officePlugin() {
5882
+ function officePlugin(options = {}) {
5859
5883
  return {
5860
5884
  name: "office",
5861
5885
  match(file) {
@@ -5868,7 +5892,11 @@ function officePlugin() {
5868
5892
  const arrayBuffer = await readArrayBuffer(ctx.file);
5869
5893
  const packageFormat = shouldSniffPackagedOffice(extension) ? await detectPackagedOfficeFormat(arrayBuffer) : void 0;
5870
5894
  let disposeDocxFit;
5871
- if (packageFormat === "docx" && !fileIsDocx(extension)) {
5895
+ let delegatedInstance;
5896
+ const conversionContext = await createOfficeConversionContext(ctx, arrayBuffer, extension, packageFormat);
5897
+ if (conversionContext && await shouldUseOfficeConversion(options, conversionContext)) {
5898
+ delegatedInstance = await renderConvertedOfficePreview(panel, ctx, options, conversionContext);
5899
+ } else if (packageFormat === "docx" && !fileIsDocx(extension)) {
5872
5900
  disposeDocxFit = await renderDocx(panel, arrayBuffer);
5873
5901
  } else if (packageFormat === "xlsx" && !sheetExtensions.has(extension)) {
5874
5902
  await renderSheet(panel, arrayBuffer, "xlsx");
@@ -5902,12 +5930,13 @@ function officePlugin() {
5902
5930
  ctx.toolbar?.refreshCommandSupport();
5903
5931
  return {
5904
5932
  canCommand(command) {
5905
- return controller?.canCommand(command) ?? false;
5933
+ return delegatedInstance?.canCommand?.(command) || controller?.canCommand(command) || false;
5906
5934
  },
5907
5935
  command(command) {
5908
- return controller?.command(command) ?? false;
5936
+ return delegatedInstance?.command?.(command) || controller?.command(command) || false;
5909
5937
  },
5910
5938
  destroy() {
5939
+ delegatedInstance?.destroy();
5911
5940
  controller?.destroy();
5912
5941
  disposeDocxFit?.();
5913
5942
  panel.remove();
@@ -5916,6 +5945,104 @@ function officePlugin() {
5916
5945
  }
5917
5946
  };
5918
5947
  }
5948
+ async function createOfficeConversionContext(ctx, arrayBuffer, extension, detectedFormat) {
5949
+ const effectiveFormat = detectedFormat || extension;
5950
+ if ((effectiveFormat === "docx" || fileIsDocx(extension)) && await docxShouldPreferTextboxLayoutFallback(arrayBuffer)) {
5951
+ return { file: ctx.file, arrayBuffer, extension, detectedFormat, reason: "complex-docx" };
5952
+ }
5953
+ if (isLegacyOfficeBinary(extension)) {
5954
+ return { file: ctx.file, arrayBuffer, extension, detectedFormat, reason: "legacy-office" };
5955
+ }
5956
+ return void 0;
5957
+ }
5958
+ async function shouldUseOfficeConversion(options, context) {
5959
+ if (!options.convert) {
5960
+ return false;
5961
+ }
5962
+ if (typeof options.preferConversion === "function") {
5963
+ return Boolean(await options.preferConversion(context));
5964
+ }
5965
+ if (options.preferConversion !== void 0) {
5966
+ return options.preferConversion;
5967
+ }
5968
+ return context.reason === "complex-docx" || context.reason === "legacy-office";
5969
+ }
5970
+ async function renderConvertedOfficePreview(panel, ctx, options, conversionContext) {
5971
+ if (!options.convert) {
5972
+ throw new Error("Office conversion handler is not configured.");
5973
+ }
5974
+ const converted = normalizeOfficeConversionResult(await options.convert(conversionContext), ctx.file.name);
5975
+ if (!converted) {
5976
+ throw new Error("Office conversion handler did not return a previewable file.");
5977
+ }
5978
+ if (converted.mimeType !== "application/pdf" && !converted.fileName.toLowerCase().endsWith(".pdf")) {
5979
+ throw new Error("Office conversion handler must return a PDF Blob, ArrayBuffer or URL.");
5980
+ }
5981
+ return renderPdfDocumentPreview({
5982
+ ...options.pdf || {},
5983
+ fileName: converted.fileName,
5984
+ fileUrl: converted.fileUrl,
5985
+ fileSize: converted.fileSize,
5986
+ isExternal: !converted.revokeUrlOnDestroy,
5987
+ viewport: panel,
5988
+ size: ctx.size,
5989
+ fit: ctx.options.fit,
5990
+ toolbar: ctx.toolbar,
5991
+ title: "Office \u9AD8\u4FDD\u771F\u8F6C\u6362\u9884\u89C8",
5992
+ fallbackTitle: "Office \u8F6C\u6362\u540E\u7684 PDF \u65E0\u6CD5\u9884\u89C8",
5993
+ revokeUrlOnDestroy: converted.revokeUrlOnDestroy
5994
+ });
5995
+ }
5996
+ function normalizeOfficeConversionResult(result, sourceFileName) {
5997
+ if (!result) {
5998
+ return void 0;
5999
+ }
6000
+ const fallbackFileName = `${stripFileExtension(sourceFileName) || "office-preview"}.pdf`;
6001
+ if (typeof result === "string") {
6002
+ return {
6003
+ fileName: fallbackFileName,
6004
+ fileUrl: result,
6005
+ mimeType: "application/pdf",
6006
+ revokeUrlOnDestroy: false
6007
+ };
6008
+ }
6009
+ if (result instanceof ArrayBuffer) {
6010
+ const blob = new Blob([result], { type: "application/pdf" });
6011
+ return createConvertedOfficeBlobPreview(blob, fallbackFileName);
6012
+ }
6013
+ if (result instanceof Blob) {
6014
+ return createConvertedOfficeBlobPreview(result, fallbackFileName);
6015
+ }
6016
+ if (result.url) {
6017
+ return {
6018
+ fileName: result.fileName || fallbackFileName,
6019
+ fileUrl: result.url,
6020
+ mimeType: result.mimeType || "application/pdf",
6021
+ revokeUrlOnDestroy: false
6022
+ };
6023
+ }
6024
+ const data = result.blob || result.data;
6025
+ if (data instanceof ArrayBuffer) {
6026
+ const blob = new Blob([data], { type: result.mimeType || "application/pdf" });
6027
+ return createConvertedOfficeBlobPreview(blob, result.fileName || fallbackFileName);
6028
+ }
6029
+ if (data instanceof Blob) {
6030
+ return createConvertedOfficeBlobPreview(data, result.fileName || fallbackFileName);
6031
+ }
6032
+ return void 0;
6033
+ }
6034
+ function createConvertedOfficeBlobPreview(blob, fileName) {
6035
+ return {
6036
+ fileName,
6037
+ fileUrl: URL.createObjectURL(blob),
6038
+ fileSize: blob.size,
6039
+ mimeType: blob.type || "application/pdf",
6040
+ revokeUrlOnDestroy: true
6041
+ };
6042
+ }
6043
+ function stripFileExtension(fileName) {
6044
+ return fileName.replace(/\.[^.]+$/, "");
6045
+ }
5919
6046
  function createOfficeZoomController(panel, ctx) {
5920
6047
  const canZoom = Boolean(
5921
6048
  panel.querySelector(".ofv-docx-document, .ofv-sheet, .ofv-pptx-viewer > div, .ofv-document, .ofv-text-block, .ofv-slide")
@@ -6012,7 +6139,7 @@ async function renderDocx(panel, arrayBuffer) {
6012
6139
  useBase64URL: true
6013
6140
  });
6014
6141
  await normalizeDocxLayout(content, arrayBuffer);
6015
- const shouldUseTextboxFallback = await docxPreviewLooksBlank(content, arrayBuffer) || await docxPreviewMissesRichTextboxContent(content, arrayBuffer);
6142
+ const shouldUseTextboxFallback = await docxPreviewLooksBlank(content, arrayBuffer) || await docxPreviewMissesRichTextboxContent(content, arrayBuffer) || await docxShouldPreferTextboxLayoutFallback(arrayBuffer);
6016
6143
  if (shouldUseTextboxFallback) {
6017
6144
  disposeFit?.();
6018
6145
  styleContainer.remove();
@@ -6095,6 +6222,27 @@ async function docxHasRichTextboxContent(arrayBuffer) {
6095
6222
  return false;
6096
6223
  }
6097
6224
  }
6225
+ async function docxShouldPreferTextboxLayoutFallback(arrayBuffer) {
6226
+ try {
6227
+ const zip = await import_jszip3.default.loadAsync(arrayBuffer);
6228
+ const documentXml = await zip.file("word/document.xml")?.async("text");
6229
+ if (!documentXml || !/\btxbxContent\b/.test(documentXml)) {
6230
+ return false;
6231
+ }
6232
+ const blocks = extractDocxTextboxBlocks(documentXml);
6233
+ const meaningfulBlocks = blocks.filter((block) => block.paragraphs.length > 0);
6234
+ const sidebarBackgrounds = blocks.filter(
6235
+ (block) => block.paragraphs.length === 0 && block.fill && block.relativeV === "page" && block.x < 0 && block.width >= 120 && block.height >= 500
6236
+ );
6237
+ const pageAnchoredTextboxes = meaningfulBlocks.filter((block) => block.relativeV === "page");
6238
+ const paragraphAnchoredTextboxes = meaningfulBlocks.filter((block) => block.relativeV !== "page");
6239
+ const leftTextboxes = meaningfulBlocks.filter((block) => block.x < 0);
6240
+ const rightTextboxes = meaningfulBlocks.filter((block) => block.x >= 80);
6241
+ return sidebarBackgrounds.length >= 2 && meaningfulBlocks.length >= 8 && pageAnchoredTextboxes.length >= 4 && paragraphAnchoredTextboxes.length >= 2 && leftTextboxes.length >= 3 && rightTextboxes.length >= 3;
6242
+ } catch {
6243
+ return false;
6244
+ }
6245
+ }
6098
6246
  async function renderDocxContentFallback(container, arrayBuffer, options = {}) {
6099
6247
  if (options.showNote !== false) {
6100
6248
  const fallbackNote = document.createElement("div");
@@ -6546,6 +6694,10 @@ function createDocxTextboxBlockElement(block) {
6546
6694
  const paragraphs = normalizeDocxTextboxParagraphOrder(block);
6547
6695
  const [first, ...rest] = paragraphs;
6548
6696
  if (first) {
6697
+ const sectionKind = getDocxTextboxSectionKind(first);
6698
+ if (sectionKind) {
6699
+ section.classList.add(`ofv-docx-textbox-section-${sectionKind}`);
6700
+ }
6549
6701
  const heading = document.createElement("h3");
6550
6702
  heading.textContent = first;
6551
6703
  section.append(heading);
@@ -6558,6 +6710,28 @@ function createDocxTextboxBlockElement(block) {
6558
6710
  }
6559
6711
  return section;
6560
6712
  }
6713
+ function getDocxTextboxSectionKind(heading) {
6714
+ const text = normalizePreviewText(heading);
6715
+ if (text.includes("\u6559\u80B2\u80CC\u666F")) {
6716
+ return "education";
6717
+ }
6718
+ if (text.includes("\u4E13\u4E1A\u6280\u80FD")) {
6719
+ return "skills";
6720
+ }
6721
+ if (text.includes("\u5DE5\u4F5C\u7ECF\u5386")) {
6722
+ return "work";
6723
+ }
6724
+ if (text.includes("\u9879\u76EE\u7ECF\u9A8C")) {
6725
+ return "projects";
6726
+ }
6727
+ if (text.includes("\u81EA\u6211\u8BC4\u4EF7")) {
6728
+ return "summary";
6729
+ }
6730
+ if (text.includes("\u57FA\u672C\u4FE1\u606F")) {
6731
+ return "profile";
6732
+ }
6733
+ return "";
6734
+ }
6561
6735
  function estimateDocxTextboxBlockHeight(block) {
6562
6736
  return Math.max(block.height, 18 + block.paragraphs.length * 14);
6563
6737
  }
@@ -6601,14 +6775,58 @@ function repairDocxHeadingShapeAlignment(page) {
6601
6775
  if (!svg) {
6602
6776
  continue;
6603
6777
  }
6604
- const width = parseCssPixelValue(svg.getAttribute("width") || svg.style.width);
6778
+ const width = parseCssPixelValue(svg.style.width) || parseCssPixelValue(svg.getAttribute("width") || "");
6605
6779
  const marginLeft = parseCssPixelValue(svg.style.marginLeft);
6606
6780
  if (width < 300 || marginLeft < 28 || marginLeft > 44) {
6607
6781
  continue;
6608
6782
  }
6609
- svg.style.marginLeft = "48px";
6783
+ const textWidth = getDocxParagraphVisibleTextWidth(paragraph);
6784
+ svg.style.marginLeft = `${formatCssNumber(Math.max(48, marginLeft + textWidth * 0.68))}pt`;
6785
+ svg.style.marginTop = `${formatCssNumber(parseCssPixelValue(svg.style.marginTop) - 4)}pt`;
6786
+ normalizeDocxHeadingShapeFill(svg);
6787
+ repairDocxHeadingTextBackground(paragraph);
6610
6788
  }
6611
6789
  }
6790
+ function normalizeDocxHeadingShapeFill(svg) {
6791
+ const headingFill = "#3f4aa3";
6792
+ const fillNodes = svg.querySelectorAll("image[fill], rect[data-ofv-docx-shape-fill]");
6793
+ for (const node of fillNodes) {
6794
+ const fill = node.getAttribute("fill") || "";
6795
+ if (fill.toLowerCase() === "#38449a") {
6796
+ node.setAttribute("fill", headingFill);
6797
+ }
6798
+ }
6799
+ }
6800
+ function repairDocxHeadingTextBackground(paragraph) {
6801
+ const textSpans = Array.from(paragraph.querySelectorAll("span")).filter(
6802
+ (element) => normalizePreviewText(element.textContent || "")
6803
+ );
6804
+ const lastTextSpan = textSpans.at(-1);
6805
+ if (!lastTextSpan || !hasWhiteBackground(lastTextSpan)) {
6806
+ return;
6807
+ }
6808
+ lastTextSpan.style.paddingRight = "3pt";
6809
+ lastTextSpan.style.paddingTop = "2pt";
6810
+ lastTextSpan.style.paddingBottom = "2pt";
6811
+ lastTextSpan.style.boxDecorationBreak = "clone";
6812
+ }
6813
+ function hasWhiteBackground(element) {
6814
+ const background = element.style.backgroundColor.replace(/\s+/g, "").toLowerCase();
6815
+ return background === "white" || background === "#fff" || background === "#ffffff" || background === "rgb(255,255,255)";
6816
+ }
6817
+ function getDocxParagraphVisibleTextWidth(paragraph) {
6818
+ let textWidth = 0;
6819
+ for (const element of paragraph.querySelectorAll("span")) {
6820
+ if (!normalizePreviewText(element.textContent || "")) {
6821
+ continue;
6822
+ }
6823
+ textWidth += pxToPt(element.getBoundingClientRect().width);
6824
+ }
6825
+ return textWidth;
6826
+ }
6827
+ function pxToPt(value) {
6828
+ return value * 0.75;
6829
+ }
6612
6830
  function repairDocxListIndentAlignment(page) {
6613
6831
  for (const paragraph of page.querySelectorAll("p[class*='ofv-docx-num-']")) {
6614
6832
  const text = normalizePreviewText(paragraph.textContent || "");