diff --git a/assets/webui/index.html b/assets/webui/index.html index 1c0cdae..59a1f82 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -140,6 +140,7 @@ + 图片 diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 35a21df..333f7f7 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -86,6 +86,7 @@ a:focus-visible { } .editor-toolbar { + --editor-control-width: 58px; display: flex; max-height: 40dvh; overflow: auto; @@ -103,8 +104,8 @@ a:focus-visible { display: grid; flex: 1 0 auto; justify-content: center; - grid-template-columns: repeat(3, max-content); - grid-template-rows: repeat(2, 28px) 16px; + grid-template-columns: repeat(3, var(--editor-control-width)); + grid-template-rows: repeat(2, var(--control-height)) 16px; column-gap: 4px; row-gap: 4px; padding: 0 8px; @@ -112,17 +113,29 @@ a:focus-visible { } .editor-image { - grid-template-columns: repeat(2, max-content); + grid-template-columns: repeat(2, var(--editor-control-width)); } .editor-actions, .editor-transform, .editor-erase { - grid-template-columns: max-content; + grid-template-columns: var(--editor-control-width); } -.editor-image > select { - grid-column: 1 / -1; +.editor-group > .button, +.editor-group > select { + min-width: 0; + width: 100%; +} + +.editor-group > select { + height: var(--control-height); + padding: 0 4px; + border: 1px solid var(--line-strong); + border-radius: 0; + background: #fff; + color: #1f2428; + text-align: center; } .editor-paragraph > .editor-field, @@ -131,11 +144,11 @@ a:focus-visible { } .editor-objects { - grid-template-columns: repeat(4, max-content); + grid-template-columns: repeat(4, var(--editor-control-width)); } .editor-group:has(#penTool) { - grid-template-columns: repeat(4, max-content); + grid-template-columns: repeat(4, var(--editor-control-width)); } .object-picker-list { @@ -208,7 +221,7 @@ a:focus-visible { } .editor-paragraph { - grid-template-columns: 54px 120px; + grid-template-columns: var(--editor-control-width) 120px; } .editor-group:has(#shapeFill) { @@ -2917,6 +2930,10 @@ button.outline-link:hover { } @media (min-width: 901px) and (max-width: 1600px) and (pointer: fine) { + .editor-toolbar { + --editor-control-width: 54px; + } + .editor-group { padding-inline: 3px; column-gap: 3px; @@ -2931,7 +2948,7 @@ button.outline-link:hover { } .editor-paragraph { - grid-template-columns: 54px 108px; + grid-template-columns: var(--editor-control-width) 108px; } .toolbar { diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index bdef776..4fe37e2 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -204,6 +204,7 @@ const el = { objectFlip: document.querySelector("#objectFlip"), imageFit: document.querySelector("#imageFit"), cropImageButton: document.querySelector("#cropImageButton"), + saveImageButton: document.querySelector("#saveImageButton"), resetCropButton: document.querySelector("#resetCropButton"), multiSelectButton: document.querySelector("#multiSelectButton"), textAlign: document.querySelector("#textAlign"), @@ -1523,7 +1524,7 @@ function openObjectStyle() { const shared = (key, fallback) => items.every(item => (item[key] ?? fallback) === (items[0][key] ?? fallback)) ? items[0][key] ?? fallback : null; el.objectLinkFields.hidden = items.length !== 1 || items[0].scoped; const link = items[0].link ? JSON.parse(items[0].link) : null; - const targetPage = link?.Goto?.Dest ? state.doc.pages.findIndex(page => page.id === link.Goto.Dest.PageID) : -1; + const targetPage = link?.Goto?.Dest ? pageIndexByID(state.doc.pages, link.Goto.Dest.PageID) : -1; el.objectLinkKind.value = items[0].linkCount > 1 ? "keep" : link?.URI ? "uri" : targetPage >= 0 ? "page" : link ? "keep" : "none"; el.objectLinkKind.disabled = items[0].linkCount > 1; el.objectLinkAddress.value = link?.URI?.URI || ""; @@ -2769,7 +2770,7 @@ async function changeDocument(name, item, ...args) { const clipboard = state.objectClipboard; if (reindexed && clipboard?.scope?.startsWith(reindexed.scope + "/") && clipboard.page === reindexed.page) state.objectClipboard = null; const location = name === "ofdgoUndo" ? view?.before : view?.after; - const restoredIndex = location ? doc.pages.findIndex(page => page.id === location.page) : -1; + const restoredIndex = location ? pageIndexByID(doc.pages, location.page) : -1; if (restoredIndex >= 0) { state.composite = location.scope ? { index: restoredIndex, key: location.scope, objects: [] } : null; } else if (!item && !inserting && !editingAnnotation && !(name === "ofdgoPasteObjects" && args[4])) { @@ -2810,7 +2811,7 @@ async function changeDocument(name, item, ...args) { if (item || name === "ofdgoPasteObjects" || inserting || editingAnnotation) { await refreshEditorPage(doc, item ? item.index : args[0], openSeq, clearSelection); } else { - const samePage = doc.pages.findIndex((page) => page.id === previous.id); + const samePage = pageIndexByID(doc.pages, previous.id); const pageIndex = restoredIndex >= 0 ? restoredIndex : doc.pageIndex ?? (samePage < 0 ? Math.min(state.pageIndex, doc.pageCount - 1) : samePage); const page = doc.pages[pageIndex]; const keepScroll = doc.pageIndex === undefined && page.id === previous.id && pageIndex === state.pageIndex @@ -4120,7 +4121,7 @@ async function openDocument(options = {}) { if (preview) state.pageCache.set(page.index, { ...preview, previewOnly: true }); } } - const indices = new Set([pageIndex, ...doc.pages.filter((page) => previewIDs.includes(page.id)).map((page) => page.index)]); + const indices = new Set([pageIndex, ...previewIDs.map(id => pageIndexByID(doc.pages, id)).filter(index => index >= 0)]); for (const index of indices) { await loadPageData(index, { openSeq, priority: 0, refresh: true }); if (openSeq !== state.openSeq) { @@ -4282,28 +4283,46 @@ async function renderPage(index, options = {}) { } } -async function downloadAttachment(attachment) { +function downloadAttachment(attachment) { + return downloadResource(attachment, "ofdgoExportAttachment", "附件"); +} + +async function saveDocumentImage(resource) { + if (!resource || !state.doc || document.body.hasAttribute("aria-busy")) return; + const seq = state.openSeq; + try { + const image = await callWASM("ofdgoImages", resource); + if (seq !== state.openSeq) return; + await downloadResource({id: image.id, fileName: image.name}, "ofdgoExportImages", "图片"); + } catch (err) { + if (seq === state.openSeq) showError(err, false); + } +} + +el.saveImageButton.addEventListener("click", () => saveDocumentImage(canvasEditor.selected?.imageResource)); + +async function downloadResource(attachment, api, label) { if (!state.doc || document.body.hasAttribute("aria-busy")) { return; } - if (state.doc.encryption?.encrypted && !window.confirm("附件将按明文下载,是否继续?")) return; + if (state.doc.encryption?.encrypted && !window.confirm(`${label}将按明文下载,是否继续?`)) return; const openSeq = state.openSeq; state.exporting = true; updateControls(); - setBusy(true, "正在读取附件", null, "正在读取附件"); + setBusy(true, `正在读取${label}`, null, `正在读取${label}`); try { const file = window.showSaveFilePicker ? await window.showSaveFilePicker({ suggestedName: attachment.fileName }) : null; if (openSeq !== state.openSeq) { return; } - const result = await callWASM("ofdgoExportAttachment", attachment.id, file); + const result = await callWASM(api, attachment.id, file); if (openSeq !== state.openSeq) { return; } if (result.blob) { downloadBytes(result.blob, result.mime, attachment.fileName); } - setStatus(`附件已下载(${formatBytes(result.size)})`); + setStatus(`${label}已下载(${formatBytes(result.size)})`); } catch (err) { if (openSeq === state.openSeq) { if (err.name === "AbortError") { @@ -5531,7 +5550,7 @@ function mountPageSVG(index, page, openSeq = state.openSeq) { function pageLinkTarget(link) { if (link.dest) { - const target = state.doc.pages.findIndex(page => page.id === link.dest.pageID); + const target = pageIndexByID(state.doc.pages, link.dest.pageID); return { href: target < 0 ? "#" : `#page-${target + 1}`, title: target < 0 ? "文档链接" : `第${target + 1}页`, activate: () => navigateDestination(link.dest) }; } if (link.attachment) { @@ -5621,7 +5640,7 @@ function sourcePoint(page, x, y) { async function navigateDestination(dest) { if (document.body.hasAttribute("aria-busy")) return; - const index = state.doc.pages.findIndex(page => page.id === dest.pageID); + const index = pageIndexByID(state.doc.pages, dest.pageID); if (index < 0) { setStatus("目标页面不存在"); return; @@ -6498,9 +6517,21 @@ function createOutlineList(outlines, path = []) { return list; } +const pageIndexes = new WeakMap(); + +function pageIndexByID(pages, id) { + let indexes = pageIndexes.get(pages); + if (!indexes) { + indexes = new Map(pages.map((page, position) => [page.id, {page, position}])); + pageIndexes.set(pages, indexes); + } + const entry = indexes.get(id); + return entry ? entry.page.index ?? entry.position : -1; +} + function selectedPageIndexes() { if (!state.pageSelection.size) return state.pageMultiSelect ? [] : [state.pageIndex]; - return state.doc.pages.filter(page => state.pageSelection.has(page.id)).map(page => page.index); + return [...state.pageSelection].map(id => pageIndexByID(state.doc.pages, id)).filter(index => index >= 0).sort((a, b) => a - b); } function changeSelectedPages(action, direction = 0) { @@ -6546,7 +6577,7 @@ function selectThumbnailPage(event, index) { const page = state.doc.pages[index], additive = event.ctrlKey || event.metaKey || state.pageMultiSelect; if (!additive) state.pageSelection.clear(); if (event.shiftKey) { - let anchor = state.doc.pages.findIndex(page => page.id === state.pageSelectionAnchor); + let anchor = pageIndexByID(state.doc.pages, state.pageSelectionAnchor); if (anchor < 0) anchor = state.pageIndex; for (let i = Math.min(anchor, index); i <= Math.max(anchor, index); i++) state.pageSelection.add(state.doc.pages[i].id); } else { @@ -7859,6 +7890,7 @@ function updateObjectControls(item, reset = false) { el.objectAlign.disabled = disabled || cropping || !canEditObject(item, "arrange"); el.objectRotate.disabled = el.objectFlip.disabled = disabled || cropping || !canEditObject(item, state.composite ? "transform" : "arrange"); el.cropImageButton.disabled = disabled || item.type !== "ImageObject" || !canEditObject(item, "cropImage"); + el.saveImageButton.disabled = disabled || !item.imageResource; el.imageFit.disabled = el.cropImageButton.disabled || cropping || Boolean(item.scoped && !canEditObject(item, "fitImage")); el.cropImageButton.textContent = cropping ? "完成" : "裁剪"; el.cropImageButton.setAttribute("aria-label", cropping ? "完成裁剪" : "裁剪图片"); @@ -7958,7 +7990,7 @@ async function callWASM(name, ...args) { request.fontData = new Map(); args.push([...request.localFonts.keys()]); } - if (name === "ofdgoConvertPDF" || name === "ofdgoExportPage" || name === "ofdgoExportDocument" || name === "ofdgoExportAttachment" || name === "ofdgoSaveDocument" || name === "ofdgoSaveEncrypted" || name === "ofdgoSaveSigned" || name === "ofdgoImportPages") { + if (name === "ofdgoExportImages" || name === "ofdgoConvertPDF" || name === "ofdgoExportPage" || name === "ofdgoExportDocument" || name === "ofdgoExportAttachment" || name === "ofdgoSaveDocument" || name === "ofdgoSaveEncrypted" || name === "ofdgoSaveSigned" || name === "ofdgoImportPages") { state.exportRequestID = id; el.cancelExportButton.hidden = false; el.cancelExportButton.disabled = false; diff --git a/assets/webui/ofdgo_wasm.js b/assets/webui/ofdgo_wasm.js index 1a36a02..b456f9d 100644 --- a/assets/webui/ofdgo_wasm.js +++ b/assets/webui/ofdgo_wasm.js @@ -29,7 +29,7 @@ self.onmessage = ({ data }) => { operations.get(data.id)?.abort(); return; } - if (data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" || data.name === "ofdgoConvertPDF" || data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoSaveDocument" || data.name === "ofdgoSaveEncrypted" || data.name === "ofdgoSaveSigned" || data.name === "ofdgoImportPages") { + if (data.name === "ofdgoExportImages" || data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" || data.name === "ofdgoConvertPDF" || data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoSaveDocument" || data.name === "ofdgoSaveEncrypted" || data.name === "ofdgoSaveSigned" || data.name === "ofdgoImportPages") { operations.set(data.id, new AbortController()); } pending = pending.then(() => data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" ? handleBatchMessage(data) : handleMessage(data)); diff --git a/assets/webui/ofdgo_work.js b/assets/webui/ofdgo_work.js index 054f38e..5f39d17 100644 --- a/assets/webui/ofdgo_work.js +++ b/assets/webui/ofdgo_work.js @@ -74,7 +74,8 @@ async function isBundleComplete(bundle) { } const cache = await caches.open(bundle.name); const keys = await cache.keys(); - return bundle.assets.every((url) => keys.some((key) => key.url === url)); + const urls = new Set(keys.map(key => key.url)); + return bundle.assets.every(url => urls.has(url)); } async function currentBundle(meta) { diff --git a/go.mod b/go.mod index 2a7c017..8419cef 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6 github.com/xiaoqidun/jbig2 v1.1.1 - github.com/xiaoqidun/pdfgo v0.0.0-20260928121523-d13a51030150 + github.com/xiaoqidun/pdfgo v0.0.0-20260928145637-87d467fc8ef1 golang.org/x/crypto v0.57.0 golang.org/x/image v0.46.0 golang.org/x/text v0.42.0 diff --git a/go.sum b/go.sum index 14b3ed5..3842b45 100644 --- a/go.sum +++ b/go.sum @@ -62,8 +62,8 @@ github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk= github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/xiaoqidun/jbig2 v1.1.1 h1:SjliS9ZO0e9HX5oYUfBuXrfFsXhWricrSz+XY4zORDI= github.com/xiaoqidun/jbig2 v1.1.1/go.mod h1:8QonVWtENmh2kc6l+leqeGl7psb79DWlHDkCpdChjfQ= -github.com/xiaoqidun/pdfgo v0.0.0-20260928121523-d13a51030150 h1:N4JwW+p/KtgiNeNGVEdqswfLvefqZXGeS6tPpYKpIJQ= -github.com/xiaoqidun/pdfgo v0.0.0-20260928121523-d13a51030150/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ= +github.com/xiaoqidun/pdfgo v0.0.0-20260928145637-87d467fc8ef1 h1:meZvn/eb/AAbr6qfsP41UZZsW/g1v67a2CUYGzoJpS0= +github.com/xiaoqidun/pdfgo v0.0.0-20260928145637-87d467fc8ef1/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index d895340..5056c0d 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -204,6 +204,8 @@ func RunWASM() { registerAsyncCallback("ofdgoExportPage", exportPage) registerAsyncCallback("ofdgoExportDocument", exportDocument) registerAsyncCallback("ofdgoExportAttachment", exportAttachment) + registerCallback("ofdgoImages", documentImages) + registerAsyncCallback("ofdgoExportImages", exportImages) registerCallback("ofdgoMatchFontFiles", matchFontFiles) registerCallback("ofdgoFontFaces", fontFaces) registerCallback("ofdgoFontFace", fontFace) @@ -651,6 +653,49 @@ func documentInfo(args []js.Value) (any, error) { return currentSession.Details(), nil } +// documentImages 读取当前文档的原始图片目录 +// 入参: args 浏览器参数 +// 返回: any 图片目录, error 读取错误 +func documentImages(args []js.Value) (any, error) { + if currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + if len(args) != 0 { + return currentSession.Reader.Image(context.Background(), args[0].String()) + } + return currentSession.Reader.Images(context.Background()) +} + +// exportImages 分块保存单幅原始图片或图片归档 +// 入参: args 图片标识和写出回调,空标识保存全部 +// 返回: any 文件类型, error 读写错误 +func exportImages(args []js.Value) (any, error) { + if currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + writer := bufio.NewWriterSize(exportWriter{write: args[1]}, 1<<20) + mime := "application/octet-stream" + if id := args[0].String(); id != "" { + input, err := currentSession.Reader.OpenImage(context.Background(), id) + if err != nil { + return nil, err + } + defer input.Close() + if _, err := io.Copy(writer, input); err != nil { + return nil, err + } + } else { + mime = "application/zip" + if err := currentSession.Reader.WriteImages(context.Background(), writer); err != nil { + return nil, err + } + } + if err := writer.Flush(); err != nil { + return nil, err + } + return successResult(map[string]any{"mime": mime}), nil +} + // exportAttachment 分块导出附件 // 入参: args 浏览器参数 // 返回: any 附件数据, error 错误信息 @@ -736,6 +781,20 @@ func renderPage(args []js.Value) (any, error) { return nil, err } links := make([]any, len(page.Links)) + attachmentNames := make(map[string]string) + for _, link := range page.Links { + if link.Attachment == "" { + continue + } + attachments, err := currentSession.Reader.Attachments() + if err != nil { + return nil, err + } + for _, attachment := range attachments { + attachmentNames[attachment.ID] = path.Base(currentSession.Reader.ResPath(attachment.FileLoc)) + } + break + } for i, link := range page.Links { item := map[string]any{ "uri": link.URI, @@ -748,13 +807,7 @@ func renderPage(args []js.Value) (any, error) { } if link.Attachment != "" { item["attachment"] = link.Attachment - attachments, _ := currentSession.Reader.Attachments() - for _, attachment := range attachments { - if attachment.ID == link.Attachment { - item["fileName"] = path.Base(currentSession.Reader.ResPath(attachment.FileLoc)) - break - } - } + item["fileName"] = attachmentNames[link.Attachment] } if dest := link.Dest; dest != nil { item["dest"] = map[string]any{"type": dest.Type, "pageID": dest.PageID, "left": dest.Left, "top": dest.Top, @@ -1184,6 +1237,9 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { } item["imageBounds"] = editorBox(full) } + if object.Type == "ImageObject" { + item["imageResource"] = object.ImageObject.ResourceID + } if object.Type == "TextObject" { item["bounds"] = editorBox(box) value, layout := object.TextObject.TextLayout() @@ -2114,6 +2170,9 @@ func compositeObjects(args []js.Value) (any, error) { item := map[string]any{"id": fmt.Sprintf("%s/%d", key, i), "type": member.Object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": i, "position": member.Position.Index, "count": member.Position.Count, "container": key + ":" + member.Position.Container, "scoped": true, "contours": member.Contours, "capabilities": editorCapabilities(member.Capabilities, member.Object.Type)} editorAppearance(item, member.Object, member.Capabilities.Paint, member.StrokeScale) + if member.Object.Type == "ImageObject" { + item["imageResource"] = member.Object.ImageObject.ResourceID + } if kind, geometry := member.Shape(); kind != "" { item["shape"], item["geometry"] = string(kind), editorBox(geometry) } else if kind, box, matrix := member.ShapeFrame(); kind != "" { diff --git a/ofdgo_extract_image.go b/ofdgo_extract_image.go new file mode 100644 index 0000000..d51b546 --- /dev/null +++ b/ofdgo_extract_image.go @@ -0,0 +1,253 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "archive/zip" + "context" + "encoding/xml" + "fmt" + "io" + "path" + "slices" + "strings" +) + +// ImageInfo 描述文档内的原始图片资源,独立蒙版保留为独立资源 +// 不包含对象裁剪、旋转、透明度及显示尺寸,不代表页面合成后的外观 +type ImageInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Format string `json:"format"` + Location string `json:"-"` +} + +// imageOutput 在分块写出图片时检查调用方取消状态 +type imageOutput struct { + context context.Context + writer io.Writer +} + +// imageInput 在读取原始图片时检查调用方取消状态 +type imageInput struct { + io.ReadCloser + context context.Context +} + +// Read 检查取消状态后读取原始数据 +// 入参: data 数据缓冲区 +// 返回: int 读取字节数, error 读取或取消错误 +func (r imageInput) Read(data []byte) (int, error) { + if err := r.context.Err(); err != nil { + return 0, err + } + return r.ReadCloser.Read(data) +} + +// Write 检查取消状态后写出原始数据 +// 入参: data 数据块 +// 返回: int 写入字节数, error 写入或取消错误 +func (w imageOutput) Write(data []byte) (int, error) { + if err := w.context.Err(); err != nil { + return 0, err + } + return w.writer.Write(data) +} + +// Images 列出文档、页面和模板声明的图片资源,不解码图片像素 +// 包含未使用及重复引用的资源声明,同一资源标识只返回一次 +// 入参: ctx 取消上下文 +// 返回: []ImageInfo 图片资源, error 读取或取消错误 +func (r *Reader) Images(ctx context.Context) ([]ImageInfo, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if r.imageCatalog != nil { + return slices.Clone(r.imageCatalog), nil + } + doc, err := r.Doc() + if err != nil { + return nil, err + } + resources := make([]string, 0) + for _, name := range append(slices.Clone(doc.CommonData.DocumentRes), doc.CommonData.PublicRes...) { + resources = append(resources, r.ResPath(name)) + } + pages := slices.Clone(doc.Pages.Page) + for _, template := range doc.CommonData.TemplatePage { + pages = append(pages, Page{BaseLoc: template.BaseLoc}) + } + for _, page := range pages { + if err := ctx.Err(); err != nil { + return nil, err + } + header, err := r.readPageHeader(page) + if err != nil { + return nil, err + } + for _, name := range header.PageRes { + resources = append(resources, resolveResourcePath(r.ResPath(page.BaseLoc), "", name)) + } + } + seen := make(map[string]bool) + ids := make(map[string]ImageInfo) + items := make([]ImageInfo, 0) + for _, name := range resources { + if err := ctx.Err(); err != nil { + return nil, err + } + if seen[name] { + continue + } + seen[name] = true + data, err := r.readFile(name) + if err != nil { + return nil, err + } + var res Res + if err := xml.Unmarshal(data, &res); err != nil { + return nil, err + } + for _, media := range res.MultiMedias.MultiMedia { + if media.Type != "Image" { + continue + } + location := resolveResourcePath(name, res.BaseLoc, media.MediaFile) + if media.ID == "" || location == "" { + return nil, fmt.Errorf("invalid image resource") + } + item := ImageInfo{ID: media.ID, Name: path.Base(location), Format: media.Format, Location: location} + if previous, ok := ids[item.ID]; ok { + if previous != item { + return nil, fmt.Errorf("conflicting image resource %q", item.ID) + } + continue + } + ids[item.ID] = item + items = append(items, item) + } + } + r.imageCatalog = items + return slices.Clone(items), nil +} + +// Image 查找指定图片,已加载资源直接读取所在资源文件,不扫描其他页面 +// 入参: ctx 取消上下文, id 图片资源标识 +// 返回: ImageInfo 图片资源, error 读取或取消错误 +func (r *Reader) Image(ctx context.Context, id string) (ImageInfo, error) { + if err := ctx.Err(); err != nil { + return ImageInfo{}, err + } + if name := r.resourceFiles[id]; name != "" { + data, err := r.readFile(name) + if err != nil { + return ImageInfo{}, err + } + var res Res + if err := xml.Unmarshal(data, &res); err != nil { + return ImageInfo{}, err + } + for _, media := range res.MultiMedias.MultiMedia { + if media.ID == id && media.Type == "Image" && media.MediaFile != "" { + location := resolveResourcePath(name, res.BaseLoc, media.MediaFile) + return ImageInfo{ID: id, Name: path.Base(location), Format: media.Format, Location: location}, nil + } + } + return ImageInfo{}, fmt.Errorf("image resource %q not found", id) + } + items, err := r.Images(ctx) + if err != nil { + return ImageInfo{}, err + } + for _, item := range items { + if item.ID == id { + return item, nil + } + } + return ImageInfo{}, fmt.Errorf("image resource %q not found", id) +} + +// OpenImage 打开原始图片资源流,不解码、不重采样或重新压缩 +// 加密文档返回解密后的资源,调用方负责关闭并完整读取以校验ZIP数据 +// 入参: ctx 取消上下文, id 图片资源标识 +// 返回: io.ReadCloser 原始图片流, error 读取错误 +func (r *Reader) OpenImage(ctx context.Context, id string) (io.ReadCloser, error) { + item, err := r.Image(ctx, id) + if err != nil { + return nil, err + } + input, err := r.openFile(item.Location) + if err != nil { + return nil, err + } + return imageInput{ReadCloser: input, context: ctx}, nil +} + +// WriteImages 将原始图片逐个写入ZIP,不改变图片编码,文件序号避免同名覆盖 +// 入参: ctx 取消上下文, writer 输出流, ids 资源标识,省略则导出全部 +// 返回: error 读取、写出或取消错误 +func (r *Reader) WriteImages(ctx context.Context, writer io.Writer, ids ...string) error { + var items []ImageInfo + if len(ids) == 0 { + var err error + items, err = r.Images(ctx) + if err != nil { + return err + } + } else { + seen := make(map[string]bool) + for _, id := range ids { + if seen[id] { + continue + } + item, err := r.Image(ctx, id) + if err != nil { + return err + } + items = append(items, item) + seen[id] = true + } + } + archive := zip.NewWriter(imageOutput{ctx, writer}) + for i, item := range items { + if err := ctx.Err(); err != nil { + return err + } + name := strings.Map(func(c rune) rune { + if c < 32 || strings.ContainsRune(`<>:"/\|?*`, c) { + return '_' + } + return c + }, item.Name) + header := &zip.FileHeader{Name: fmt.Sprintf("%04d-%s", i+1, name), Method: zip.Store} + output, err := archive.CreateHeader(header) + if err != nil { + return err + } + input, err := r.openFile(item.Location) + if err != nil { + return err + } + _, err = io.Copy(imageOutput{ctx, output}, input) + closeErr := input.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + } + return archive.Close() +} diff --git a/ofdgo_geom_path.go b/ofdgo_geom_path.go index 575f695..d36998b 100644 --- a/ofdgo_geom_path.go +++ b/ofdgo_geom_path.go @@ -153,8 +153,19 @@ func objectGeometryPath(geometry GeometryBackend, object PathObject) (GeometryPa // 入参: path 填充路径 // 返回: GeometryPath 显式闭合路径 func closedGeometry(path GeometryPath) GeometryPath { + open, missing := false, false + for _, segment := range path { + if segment.Verb == GeometryMove && open { + missing = true + break + } + open = segment.Verb != GeometryClose + } + if !missing && !open { + return path + } result := make(GeometryPath, 0, len(path)+1) - open := false + open = false for _, segment := range path { if segment.Verb == GeometryMove && open { result = append(result, GeometrySegment{Verb: GeometryClose}) diff --git a/ofdgo_reader.go b/ofdgo_reader.go index 6eb0957..518ba29 100644 --- a/ofdgo_reader.go +++ b/ofdgo_reader.go @@ -36,6 +36,7 @@ type Reader struct { ResMap map[string]string resourcesRead map[string]bool resourceFiles map[string]string + imageCatalog []ImageInfo fontCache map[string]*Font fontResourcesRead bool fontFaces map[string][]*FontFace diff --git a/ofdgo_renderer.go b/ofdgo_renderer.go index e937c02..ed9d00b 100644 --- a/ofdgo_renderer.go +++ b/ofdgo_renderer.go @@ -21,6 +21,7 @@ import ( // Renderer 渲染器实现 // 通过NewRenderer创建,实例及共享的Reader需串行使用 +// 外部字体缺失且无可用回退时不绘制对应文字,其余内容继续;字体状态可通过FontInfos查询 // OnPageText可选,接收页面绘制时同步提取的文字,不包含图案和签名外观 // OnExportProgress可选,同步回报文档导出的已处理页数及总页数,返回错误则停止,页数完成不代表写入成功 // TransparentBackground关闭页面白底,供嵌套图案和印章保持透明背景 diff --git a/ofdgo_renderer_font_loader_canvas.go b/ofdgo_renderer_font_loader_canvas.go index f64d64e..c46d6e6 100644 --- a/ofdgo_renderer_font_loader_canvas.go +++ b/ofdgo_renderer_font_loader_canvas.go @@ -14,11 +14,7 @@ package ofdgo -import ( - "fmt" - - "github.com/tdewolff/canvas" -) +import "github.com/tdewolff/canvas" // loadFont 加载字体 // 入参: fontID 字体ID @@ -34,13 +30,6 @@ func (r *Renderer) loadFont(fontID string) *canvas.FontFamily { } of := r.Reader.fontCache[fontID] if len(resolved.Data) == 0 { - if !r.textOnly { - name := fontID - if of != nil && of.FontName != "" { - name = of.FontName - } - r.renderError = fmt.Errorf("font %q is unavailable", name) - } return nil } if of == nil { diff --git a/ofdgo_sign_verify.go b/ofdgo_sign_verify.go index 5357edd..cd097b2 100644 --- a/ofdgo_sign_verify.go +++ b/ofdgo_sign_verify.go @@ -883,13 +883,36 @@ func verifySignatureCertificateChain(cert *smx509.Certificate, pool, trusts [][] if err != nil { return err } - group.pool.AddCert(ca) + addSignatureCertificate(group.pool, ca) } } _, err := cert.Verify(smx509.VerifyOptions{Roots: roots, Intermediates: intermediates, CurrentTime: at, KeyUsages: []smx509.ExtKeyUsage{usage}}) return err } +// addSignatureCertificate 添加证书并按RFC5280排除自颁发中间证书的路径长度 +// 入参: pool 证书池, cert 证书 +func addSignatureCertificate(pool *smx509.CertPool, cert *smx509.Certificate) { + if !cert.BasicConstraintsValid || cert.MaxPathLen < 0 { + pool.AddCert(cert) + return + } + copy := *cert + copy.MaxPathLen, copy.MaxPathLenZero = -1, false + pool.AddCertWithConstraint(©, func(chain []*smx509.Certificate) error { + length := 0 + for i, intermediate := range chain { + if i > 0 && !bytes.Equal(intermediate.RawSubject, intermediate.RawIssuer) { + length++ + } + } + if length > cert.MaxPathLen { + return smx509.CertificateInvalidError{Cert: cert, Reason: smx509.TooManyIntermediates} + } + return nil + }) +} + // verifyCertificateSignature 验证证书签名 // 入参: cert 证书信息, issuerCert 颁发者证书 // 返回: bool 是否验证通过, error 错误信息