diff --git a/assets/webui/index.html b/assets/webui/index.html index 59a1f82..76dfe07 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -164,7 +164,7 @@ 擦除
- + @@ -284,11 +284,14 @@
+
-
+ + +
mm
@@ -298,10 +301,12 @@
mm
+ +
- +
diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 42cd702..05fb07a 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -55,6 +55,8 @@ const state = { importPageCount: 0, importEncrypted: false, importing: false, + imageImporting: false, + createInsert: false, pageSelection: new Set(), pageSelectionAnchor: null, pageMultiSelect: false, @@ -359,6 +361,12 @@ const el = { pageCancel: document.querySelector("#pageCancel"), saveButton: document.querySelector("#saveButton"), createPanel: document.querySelector("#createPanel"), + createMode: document.querySelector("#createMode"), + createImages: document.querySelector("#createImages"), + createSize: document.querySelector("#createSize"), + createMargin: document.querySelector("#createMargin"), + createPosition: document.querySelector("#createPosition"), + createSubmit: document.querySelector("#createSubmit"), createForm: document.querySelector("#createForm"), createName: document.querySelector("#createName"), createWidth: document.querySelector("#createWidth"), @@ -886,6 +894,7 @@ el.addPageButton.addEventListener("change", async () => { if (canvasEditor.input && !await canvasEditor.commitText()) return; if (canvasEditor.crop && !await canvasEditor.commitCrop()) return; if (action === "import") { openImportPanel(); return; } + if (action === "images") { openCreatePanel(true); return; } const page = currentPageInfo(); return changeDocument("ofdgoChangePage", null, "add", state.pageIndex, page.width, page.height); }); @@ -1110,7 +1119,13 @@ el.editButton.addEventListener("click", toggleEditor); el.editButton.addEventListener("pointerdown", (event) => { if (canvasEditor.input) event.preventDefault(); }); -el.createCancel.addEventListener("click", () => el.createPanel.close()); +el.createCancel.addEventListener("click", () => state.imageImporting ? cancelExport() : el.createPanel.close()); +el.createPanel.addEventListener("cancel", event => { + if (state.imageImporting) { event.preventDefault(); cancelExport(); } +}); +el.createPanel.addEventListener("close", () => { el.createImages.value = ""; }); +el.createMode.addEventListener("change", updateCreateOptions); +el.createSize.addEventListener("change", updateCreateOptions); el.createForm.addEventListener("submit", createDocument); editorClick(el.insertTextButton, toggleTextTool); editorClick(el.insertImageButton, () => openInsertPanel()); @@ -1987,30 +2002,75 @@ function confirmTextReflow(item) { || window.confirm("将重新排版原文,是否继续?")); } -function openCreatePanel() { +function openCreatePanel(insert = false) { if (document.body.hasAttribute("aria-busy")) { return; } el.createForm.reset(); + state.createInsert = insert === true; + el.createMode.value = state.createInsert ? "images" : "blank"; + el.createSubmit.textContent = state.createInsert ? "插入" : "新建"; + el.createPanel.setAttribute("aria-label", state.createInsert ? "插入图片" : "新建文档"); + el.createCancel.disabled = false; + updateCreateOptions(); el.createStatus.textContent = ""; el.createPanel.showModal(); } +function updateCreateOptions() { + const images = el.createMode.value === "images", busy = Boolean(state.imageImporting); + const toggle = (id, visible) => { document.getElementById(id).hidden = !visible; }; + toggle("createModeRow", !state.createInsert); + el.createName.closest(".form-row").hidden = Boolean(state.createInsert); + el.createName.disabled = busy || Boolean(state.createInsert); + el.createMode.disabled = busy; + for (const [id, input] of [["createImagesRow", el.createImages], ["createSizeRow", el.createSize], ["createMarginRow", el.createMargin]]) { + toggle(id, images); + input.disabled = busy || !images; + } + el.createImages.required = images; + const custom = !images || el.createSize.value === "custom"; + toggle("createDimensions", custom); + el.createWidth.disabled = el.createHeight.disabled = busy || !custom; + toggle("createPositionRow", Boolean(state.createInsert)); + el.createPosition.disabled = busy || !state.createInsert; + el.createSubmit.disabled = busy; +} + +function imagePageRequest() { + const files = [...el.createImages.files].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + const size = el.createSize.value; + return [files, { + Width: size === "image" ? 0 : Number(el.createWidth.value), + Height: size === "image" ? 0 : Number(el.createHeight.value), + Margin: Number(el.createMargin.value), + }]; +} + async function createDocument(event) { event.preventDefault(); + if (state.createInsert) { + const at = { before: state.pageIndex, after: state.pageIndex + 1, first: 0, last: state.doc.pageCount }[el.createPosition.value]; + if (await changeDocument("ofdgoImportImages", null, ...imagePageRequest(), at, "", state.renderAnnotations)) el.createPanel.close(); + return; + } if (document.body.hasAttribute("aria-busy") || !discardChanges()) { return; } const title = el.createName.value.trim() || "未命名"; clearSecurityForms(); const openSeq = ++state.openSeq; + const images = el.createMode.value === "images"; + state.imageImporting = images; setBusy(true, "正在新建文档", null, "正在新建文档"); try { await ensureWASM(); if (openSeq !== state.openSeq) { return; } - const doc = await callWASM("ofdgoCreateDocument", title, Number(el.createWidth.value), Number(el.createHeight.value), state.renderAnnotations); + const doc = images + ? await callWASM("ofdgoImportImages", ...imagePageRequest(), -1, title, state.renderAnnotations) + : await callWASM("ofdgoCreateDocument", title, Number(el.createWidth.value), Number(el.createHeight.value), state.renderAnnotations); if (openSeq !== state.openSeq) { return; } @@ -2039,6 +2099,12 @@ async function createDocument(event) { el.createStatus.textContent = err.message; } } finally { + if (images) { + state.imageImporting = false; + state.exportRequestID = 0; + el.cancelExportButton.hidden = true; + el.createCancel.disabled = false; + } if (openSeq === state.openSeq) { setBusy(false); updateControls(); @@ -2731,7 +2797,8 @@ async function changeDocument(name, item, ...args) { const before = editorLocation(), revision = state.editorInfo.revision; const restoring = name === "ofdgoUndo" || name === "ofdgoRedo"; const { scrollLeft, scrollTop } = el.viewerPanel; - const importing = name === "ofdgoImportPages"; + const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages"; + state.imageImporting = name === "ofdgoImportImages"; if (importing) state.importing = true; setBusy(true, importing ? "正在导入" : "", null); try { @@ -2847,6 +2914,8 @@ async function changeDocument(name, item, ...args) { el.pageStatus.textContent = err.message; } else if (el.paragraphPanel.open) { el.paragraphStatus.textContent = err.message; + } else if (el.createPanel.open) { + el.createStatus.textContent = err.message; } else if (el.importPanel.open) { el.importStatus.textContent = err.message; } else { @@ -2856,6 +2925,8 @@ async function changeDocument(name, item, ...args) { } finally { if (importing) { state.importing = false; + state.imageImporting = false; + el.createCancel.disabled = false; state.exportRequestID = 0; el.cancelExportButton.hidden = true; el.importCancel.disabled = false; @@ -3299,6 +3370,13 @@ async function loadWASM() { } } } else if (data.type === "import") { + if (wasmRequests.get(data.id)?.openSeq === state.openSeq && state.imageImporting) { + const text = data.phase === "commit" ? "正在完成" : `正在导入 ${data.completed} / ${data.total} 张`; + el.createStatus.textContent = text; + setProgress(text, data.total ? data.completed / data.total * 100 : null); + if (data.phase === "commit") el.createCancel.disabled = el.cancelExportButton.disabled = true; + return; + } if (wasmRequests.get(data.id)?.openSeq === state.openSeq && state.importing && !el.importCancel.disabled) { const label = { ids: "正在检查", pages: "正在导入", resources: "正在读取", commit: "正在完成" }[data.phase]; el.importStatus.textContent = data.total ? `${label} ${data.completed} / ${data.total} 页` : label; @@ -5100,7 +5178,10 @@ function cancelExport() { return; } el.cancelExportButton.disabled = true; - if (state.importing) { + if (state.imageImporting) { + el.createCancel.disabled = true; + el.createStatus.textContent = "正在取消"; + } else if (state.importing) { el.importCancel.disabled = true; el.importStatus.textContent = "正在取消"; } @@ -7991,11 +8072,11 @@ async function callWASM(name, ...args) { request.fontData = new Map(); args.push([...request.localFonts.keys()]); } - if (name === "ofdgoExportImages" || name === "ofdgoConvertPDF" || name === "ofdgoExportPage" || name === "ofdgoExportDocument" || name === "ofdgoExportAttachment" || name === "ofdgoSaveDocument" || name === "ofdgoSaveEncrypted" || name === "ofdgoSaveSigned" || name === "ofdgoImportPages") { + if (name === "ofdgoImportImages" || 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; - el.cancelExportButton.title = { ofdgoConvertPDF: "取消转换", ofdgoSaveDocument: "取消保存", ofdgoSaveEncrypted: "取消保存", ofdgoSaveSigned: "取消签署", ofdgoImportPages: "取消导入", ofdgoExportAttachment: "取消下载" }[name] || "取消导出"; + el.cancelExportButton.title = { ofdgoImportImages: "取消导入", ofdgoConvertPDF: "取消转换", ofdgoSaveDocument: "取消保存", ofdgoSaveEncrypted: "取消保存", ofdgoSaveSigned: "取消签署", ofdgoImportPages: "取消导入", ofdgoExportAttachment: "取消下载" }[name] || "取消导出"; el.cancelExportButton.setAttribute("aria-label", el.cancelExportButton.title); } try { @@ -8026,7 +8107,8 @@ function setBusy(busy, text = "", percent = 0, status = "") { renderSecurity(); updateDisplayControls(); el.editButton.disabled = busy || Boolean(sealPreview) || !state.doc || !state.ready || state.exporting; - el.createForm.inert = busy; + el.createForm.inert = busy && !state.imageImporting; + updateCreateOptions(); el.insertForm.inert = busy; el.pageForm.inert = busy; el.paragraphForm.inert = busy; diff --git a/assets/webui/ofdgo_wasm.js b/assets/webui/ofdgo_wasm.js index b456f9d..32e2bbc 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 === "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") { + if (data.name === "ofdgoImportImages" || 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)); @@ -165,7 +165,7 @@ async function handleMessage({ id, name, args }) { let output; let channel; const signal = operations.get(id)?.signal; - const importing = name === "ofdgoImportPages"; + const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages"; const converting = name === "ofdgoConvertPDF"; const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveEncrypted" || name === "ofdgoSaveSigned"; const key = name === "ofdgoOpen" ? args[3]?.key : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.key : null; @@ -173,6 +173,14 @@ async function handleMessage({ id, name, args }) { const password = converting ? args[2] : name === "ofdgoOpen" ? args[3]?.password : name === "ofdgoSaveEncrypted" || name === "ofdgoLoadImport" ? args[1]?.password : null; try { signal?.throwIfAborted(); + if (name === "ofdgoImportImages") { + const files = args[0], reader = new FileReaderSync(); + args[0] = files.map(file => file.name); + args.push(index => { + try { return { bytes: new Uint8Array(reader.readAsArrayBuffer(files[index])) }; } + catch (err) { return { error: err.message }; } + }); + } const chunks = []; let size = 0; if (signal) { diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 5056c0d..0df9185 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -210,6 +210,7 @@ func RunWASM() { registerCallback("ofdgoFontFaces", fontFaces) registerCallback("ofdgoFontFace", fontFace) registerCallback("ofdgoCreateDocument", createDocument) + registerAsyncCallback("ofdgoImportImages", importImages) registerCallback("ofdgoEditDocument", editDocument) registerCallback("ofdgoUpdateInfo", updateInfo) registerCallback("ofdgoChangePage", changePage) @@ -3528,6 +3529,48 @@ func importPages(args []js.Value) (any, error) { return editorPageInfo{info, at}, err } +// importImages 逐张读取图片并建页,负数位置表示新建文档 +// 入参: args 文件名、选项、插入位置、标题、注解设置、读取与进度回调 +// 返回: any 文档信息, error 错误信息 +func importImages(args []js.Value) (any, error) { + var options ofdgo.ImagePageOptions + if err := json.Unmarshal([]byte(js.Global().Get("JSON").Call("stringify", args[1]).String()), &options); err != nil { + return nil, err + } + at := args[2].Int() + editor := currentEditor + if at < 0 { + editor = ofdgo.NewEditor() + editor.Info.Title = args[3].String() + } else if editor == nil { + return nil, fmt.Errorf("document is not editable") + } + if args[0].Length() == 0 { + return nil, fmt.Errorf("请选择图片") + } + sources := make([]ofdgo.ImagePageSource, args[0].Length()) + for i := range sources { + sources[i] = ofdgo.ImagePageSource{Name: args[0].Index(i).String(), Read: func(context.Context) ([]byte, error) { + result := args[5].Invoke(i) + if message := result.Get("error"); message.Type() == js.TypeString { + return nil, fmt.Errorf("%s", message.String()) + } + return bytesFromJS(result.Get("bytes")) + }} + } + options.OnProgress = editorOperationProgress(args[6]) + position := max(at, 0) + if err := editor.ImportImages(context.Background(), sources, position, options); err != nil { + return nil, err + } + if at < 0 { + editor.SetHistoryLimit(100) + } + clearImport() + info, err := previewEditor(editor, args[4].Bool()) + return editorPageInfo{info, position}, err +} + // createDocument 新建单页文档 // 入参: args 标题、纸张宽高和注解设置 // 返回: any 文档信息, error 错误信息 diff --git a/ofdgo_editor_images.go b/ofdgo_editor_images.go new file mode 100644 index 0000000..5d4f5f2 --- /dev/null +++ b/ofdgo_editor_images.go @@ -0,0 +1,173 @@ +// 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 ( + "bytes" + "context" + "fmt" + "image" + "math" +) + +// ImagePageSource 图片页面来源,按列表顺序逐张读取,每张图片处理完成后不再引用返回数据 +// Read不可重入修改编辑器,取消时应返回上下文错误 +type ImagePageSource struct { + Name string + Read func(context.Context) ([]byte, error) +} + +// ImagePageOptions 图片建页选项,尺寸和边距单位为毫米 +// Width和Height均为零时随图建页,否则按纸张等比居中,不裁剪图片 +// DPI为缺少物理尺寸时的分辨率,零值使用96;图片方向和有效密度优先采用原图信息 +// OnProgress按images、commit阶段回报进度,返回错误则撤销整个操作,不可重入修改编辑器 +type ImagePageOptions struct { + Width, Height float64 + Margin float64 + DPI float64 + OnProgress func(stage string, completed, total int) error +} + +// ImportImages 将PNG、JPEG或JBIG2逐张建页并插入指定位置,一次操作计入一条撤销记录 +// 保留像素及透明度,方向通过对象变换实现,不重新采样;图片压缩沿用AddImage规则 +// 失败或取消时不修改文档,读取期间仍需容纳单张图片的解码数据 +// 入参: ctx 取消上下文, sources 按页面顺序排列的图片, at 插入位置, options 建页选项 +// 返回: error 错误信息 +func (e *Editor) ImportImages(ctx context.Context, sources []ImagePageSource, at int, options ImagePageOptions) error { + if at < 0 || at > len(e.pages) { + return fmt.Errorf("page index %d out of range", at) + } + if !finite(options.Width) || !finite(options.Height) || !finite(options.Margin) || !finite(options.DPI) || options.Margin < 0 || options.DPI < 0 { + return fmt.Errorf("invalid image page options") + } + if (options.Width != 0 || options.Height != 0) && (options.Width <= 2*options.Margin || options.Height <= 2*options.Margin) { + return fmt.Errorf("page margins leave no image area") + } + if options.DPI == 0 { + options.DPI = 96 + } + for _, source := range sources { + if source.Read == nil { + return fmt.Errorf("image %q has no reader", source.Name) + } + } + check := func(stage string, completed int) error { + if err := ctx.Err(); err != nil { + return err + } + if options.OnProgress != nil { + if err := options.OnProgress(stage, completed, len(sources)); err != nil { + return err + } + } + return ctx.Err() + } + if err := check("images", 0); err != nil || len(sources) == 0 { + return err + } + return e.Transaction(func(e *Editor) error { + indexes := make([]int, 0, len(sources)) + for i, source := range sources { + if err := ctx.Err(); err != nil { + return err + } + data, err := source.Read(ctx) + if err != nil { + return fmt.Errorf("image %q: %w", source.Name, err) + } + page, err := e.addImagePage(data, options) + if err != nil { + return fmt.Errorf("image %q: %w", source.Name, err) + } + indexes = append(indexes, page) + if err := check("images", i+1); err != nil { + return err + } + } + if err := e.MovePages(indexes, at); err != nil { + return err + } + return check("commit", len(sources)) + }) +} + +// addImagePage 保留原图并按方向、物理尺寸与纸张选项建页 +func (e *Editor) addImagePage(data []byte, options ImagePageOptions) (int, error) { + config, format, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return 0, err + } + if format != "png" && format != "jpeg" && format != "jbig2" { + return 0, fmt.Errorf("only PNG, JPEG and JBIG2 images are supported") + } + if _, _, err := image.Decode(bytes.NewReader(data)); err != nil { + return 0, err + } + metadata, err := imagePageMetadata(data, format, options.DPI) + if err != nil { + return 0, err + } + w, h := float64(config.Width)*25.4/metadata.xdpi, float64(config.Height)*25.4/metadata.ydpi + if metadata.orientation >= 5 { + w, h = h, w + } + pw, ph := options.Width, options.Height + if pw == 0 && ph == 0 { + pw, ph = w+2*options.Margin, h+2*options.Margin + } else { + scale := math.Min((pw-2*options.Margin)/w, (ph-2*options.Margin)/h) + w, h = w*scale, h*scale + } + if !finite(w) || !finite(h) || w <= 0 || h <= 0 { + return 0, fmt.Errorf("invalid image page dimensions") + } + resource, err := e.AddImage(data) + if err != nil { + return 0, err + } + page, err := e.AddPage(pw, ph) + if err != nil { + return 0, err + } + matrix := imagePageOrientation(metadata.orientation, w, h) + _, err = e.AddObject(page, GraphicObject{Type: "ImageObject", ImageObject: ImageObject{ + ResourceID: resource, + Boundary: fmt.Sprintf("%s %s %s %s", ofdNumber((pw-w)/2), ofdNumber((ph-h)/2), ofdNumber(w), ofdNumber(h)), + CTM: matrix.String(), + }}) + return page, err +} + +// imagePageOrientation 将EXIF方向映射为单位图像到页面局部坐标的变换 +func imagePageOrientation(orientation int, w, h float64) Matrix { + switch orientation { + case 2: + return Matrix{a: -w, d: h, e: w} + case 3: + return Matrix{a: -w, d: -h, e: w, f: h} + case 4: + return Matrix{a: w, d: -h, f: h} + case 5: + return Matrix{b: h, c: w} + case 6: + return Matrix{b: h, c: -w, e: w} + case 7: + return Matrix{b: -h, c: -w, e: w, f: h} + case 8: + return Matrix{b: -h, c: w, f: h} + default: + return Matrix{a: w, d: h} + } +} diff --git a/ofdgo_image_metadata.go b/ofdgo_image_metadata.go new file mode 100644 index 0000000..8750c1a --- /dev/null +++ b/ofdgo_image_metadata.go @@ -0,0 +1,189 @@ +// 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 ( + "bytes" + "encoding/binary" + "fmt" + "hash/crc32" +) + +// imagePageInfo 图片方向与像素密度,不改变原始编码 +type imagePageInfo struct { + orientation int + xdpi, ydpi float64 +} + +// imagePageMetadata 读取PNG物理尺寸与JPEG的JFIF、EXIF信息,EXIF密度优先于JFIF +func imagePageMetadata(data []byte, format string, dpi float64) (imagePageInfo, error) { + info := imagePageInfo{orientation: 1, xdpi: dpi, ydpi: dpi} + var exif []byte + if format == "png" { + for offset := 8; offset+12 <= len(data); { + n := uint64(binary.BigEndian.Uint32(data[offset:])) + if n > uint64(len(data)-offset-12) { + return info, fmt.Errorf("truncated PNG chunk") + } + end := offset + 8 + int(n) + kind, payload := string(data[offset+4:offset+8]), data[offset+8:end] + if kind == "pHYs" || kind == "eXIf" { + if crc32.ChecksumIEEE(data[offset+4:end]) != binary.BigEndian.Uint32(data[end:]) { + return info, fmt.Errorf("invalid PNG %s checksum", kind) + } + } + if kind == "pHYs" && len(payload) == 9 { + x, y := float64(binary.BigEndian.Uint32(payload)), float64(binary.BigEndian.Uint32(payload[4:])) + if x > 0 && y > 0 { + if payload[8] == 1 { + info.xdpi, info.ydpi = x*0.0254, y*0.0254 + } else if payload[8] == 0 { + info.ydpi = dpi * y / x + } + } + } else if kind == "eXIf" { + exif = payload + } + offset = end + 4 + if kind == "IEND" { + break + } + } + } else if format == "jpeg" { + for offset := 2; offset < len(data); { + if data[offset] != 0xff { + return info, fmt.Errorf("invalid JPEG marker") + } + for offset < len(data) && data[offset] == 0xff { + offset++ + } + if offset >= len(data) { + break + } + marker := data[offset] + offset++ + if marker == 0xda || marker == 0xd9 { + break + } + if marker == 0x01 || marker >= 0xd0 && marker <= 0xd7 { + continue + } + if offset+2 > len(data) { + return info, fmt.Errorf("truncated JPEG marker") + } + n := int(binary.BigEndian.Uint16(data[offset:])) + if n < 2 || n > len(data)-offset { + return info, fmt.Errorf("truncated JPEG segment") + } + payload := data[offset+2 : offset+n] + if marker == 0xe0 && len(payload) >= 14 && bytes.Equal(payload[:5], []byte("JFIF\x00")) { + x, y := float64(binary.BigEndian.Uint16(payload[8:])), float64(binary.BigEndian.Uint16(payload[10:])) + if x > 0 && y > 0 { + switch payload[7] { + case 0: + info.ydpi = dpi * y / x + case 1: + info.xdpi, info.ydpi = x, y + case 2: + info.xdpi, info.ydpi = x*2.54, y*2.54 + } + } + } else if marker == 0xe1 && bytes.HasPrefix(payload, []byte("Exif\x00\x00")) { + exif = payload[6:] + } + offset += n + } + } + if len(exif) != 0 { + orientation, x, y, err := imageExifMetadata(exif) + if err != nil { + return info, err + } + info.orientation = orientation + // PNG的物理尺寸由pHYs定义,EXIF只用于方向。 + if format == "jpeg" && x > 0 && y > 0 { + info.xdpi, info.ydpi = x, y + } + } + return info, nil +} + +// imageExifMetadata 只读取主图IFD中的方向和密度,不跟随缩略图与其他目录 +func imageExifMetadata(data []byte) (int, float64, float64, error) { + invalid := fmt.Errorf("invalid EXIF image metadata") + if len(data) < 8 { + return 0, 0, 0, invalid + } + var order binary.ByteOrder + switch string(data[:2]) { + case "II": + order = binary.LittleEndian + case "MM": + order = binary.BigEndian + default: + return 0, 0, 0, invalid + } + if order.Uint16(data[2:]) != 42 { + return 0, 0, 0, invalid + } + offset := uint64(order.Uint32(data[4:])) + if offset < 8 || offset+2 > uint64(len(data)) { + return 0, 0, 0, invalid + } + count := uint64(order.Uint16(data[offset:])) + if count*12+offset+6 > uint64(len(data)) { + return 0, 0, 0, invalid + } + orientation, unit := 1, 2 + var x, y float64 + for i := uint64(0); i < count; i++ { + entry := data[offset+2+i*12 : offset+14+i*12] + tag, kind, count := order.Uint16(entry), order.Uint16(entry[2:]), order.Uint32(entry[4:]) + switch tag { + case 0x112, 0x128: + if kind != 3 || count != 1 { + return 0, 0, 0, invalid + } + value := int(order.Uint16(entry[8:])) + if tag == 0x112 { + if value < 1 || value > 8 { + return 0, 0, 0, invalid + } + orientation = value + } else { + unit = value + } + case 0x11a, 0x11b: + at := uint64(order.Uint32(entry[8:])) + if kind != 5 || count != 1 || at+8 > uint64(len(data)) { + return 0, 0, 0, invalid + } + n, d := order.Uint32(data[at:]), order.Uint32(data[at+4:]) + if n > 0 && d > 0 { + if tag == 0x11a { + x = float64(n) / float64(d) + } else { + y = float64(n) / float64(d) + } + } + } + } + if unit == 3 { + x, y = x*2.54, y*2.54 + } else if unit != 2 { + x, y = 0, 0 + } + return orientation, x, y, nil +} diff --git a/ofdgo_renderer_output_canvas.go b/ofdgo_renderer_output_canvas.go index 152d02a..6b7d1bd 100644 --- a/ofdgo_renderer_output_canvas.go +++ b/ofdgo_renderer_output_canvas.go @@ -48,9 +48,14 @@ func (CanvasBackend) RenderSVG(r *Renderer, page *PageContent, writer io.Writer, return SVGResources{}, err } buffer := bufio.NewWriter(writer) - renderer := svg.New(buffer, c.W, c.H, nil) + renderer := &svgResourceRenderer{SVG: svg.New(buffer, c.W, c.H, nil), writer: buffer, embeddedFonts: true} io.WriteString(buffer, svgCreatorMetadata) + io.WriteString(buffer, ``) c.RenderTo(renderer) + if renderer.err != nil { + return SVGResources{}, renderer.err + } + io.WriteString(buffer, ``) if err := renderer.Close(); err != nil { return SVGResources{}, err } diff --git a/ofdgo_renderer_svg_canvas.go b/ofdgo_renderer_svg_canvas.go index 9e2a508..a29e6b2 100644 --- a/ofdgo_renderer_svg_canvas.go +++ b/ofdgo_renderer_svg_canvas.go @@ -18,6 +18,7 @@ import ( "bufio" "bytes" "crypto/sha256" + "encoding/base64" "fmt" "html" "image" @@ -36,16 +37,17 @@ const svgCreatorMetadata = `` + creatorXMP + `` // svgResourceRenderer 分离外部资源的SVG渲染器 type svgResourceRenderer struct { *svg.SVG - renderer *Renderer - writer io.Writer - fonts []SVGFont - images []SVGImage - imageNames map[image.Image]string - seen map[string]bool - styles strings.Builder - err error - objects map[*GraphicObject]string - objectStack []svgObjectGroup + renderer *Renderer + writer io.Writer + fonts []SVGFont + images []SVGImage + imageNames map[image.Image]string + seen map[string]bool + styles strings.Builder + err error + objects map[*GraphicObject]string + objectStack []svgObjectGroup + embeddedFonts bool } // svgObjectGroup 记录编辑分组的稳定路径,不把底纹内部绘制当作成员 @@ -58,13 +60,10 @@ type svgObjectGroup struct { // RenderImage 绘制图片,分离资源时保持原始编码、尺寸和变换 // 入参: img 图片对象, m 变换矩阵 func (s *svgResourceRenderer) RenderImage(img image.Image, m canvas.Matrix) { - if s.imageNames == nil { - s.SVG.RenderImage(img, m) - return - } name, ok := s.imageNames[img] + var resource SVGImage if !ok { - resource := SVGImage{MIME: "image/png"} + resource = SVGImage{MIME: "image/png"} if encoded, ok := img.(*canvasimage.Image); ok { resource.MIME, resource.Data = encoded.Mimetype, encoded.Bytes } else { @@ -75,22 +74,46 @@ func (s *svgResourceRenderer) RenderImage(img image.Image, m canvas.Matrix) { } resource.Data = buffer.Bytes() } - name = fmt.Sprintf("ofdgo-image-%x", sha256.Sum256(resource.Data)) - resource.Name = name - s.imageNames[img] = name - if !s.seen[name] { - s.seen[name] = true - s.images = append(s.images, resource) + if s.imageNames != nil { + name = fmt.Sprintf("ofdgo-image-%x", sha256.Sum256(resource.Data)) + resource.Name = name + s.imageNames[img] = name + if !s.seen[name] { + s.seen[name] = true + s.images = append(s.images, resource) + } } } _, height := s.Size() size := img.Bounds().Size() - fmt.Fprintf(s.writer, ``, m.Translate(0, float64(size.Y)).ToSVG(height), size.X, size.Y, name) + m = m.Translate(0, float64(size.Y)) + // 直接写出换轴后的矩阵,原坐标平移为零时仍需保留页面高度补偿。 + matrix := Matrix{a: m[0][0], b: -m[1][0], c: -m[0][1], d: m[1][1], e: m[0][2], f: height - m[1][2]} + fmt.Fprintf(s.writer, ``) } // RenderText 绘制文字并记录实际使用的字体资源 // 入参: text 文字对象, m 变换矩阵 func (s *svgResourceRenderer) RenderText(text *canvas.Text, m canvas.Matrix) { + if s.embeddedFonts { + s.SVG.RenderText(text, m) + return + } if text.Empty() { return } @@ -137,6 +160,8 @@ func (r *Renderer) renderSVGResources(page *PageContent, writer io.Writer, image seen: make(map[string]bool), } io.WriteString(buffer, svgCreatorMetadata) + // 图片方向由文档变换决定,不再由浏览器应用EXIF方向。 + io.WriteString(buffer, ``) if images { s.imageNames = make(map[image.Image]string) } @@ -161,6 +186,7 @@ func (r *Renderer) renderSVGResources(page *PageContent, writer io.Writer, image if s.err != nil { return SVGResources{}, s.err } + io.WriteString(buffer, ``) s.SetCustomStyle(s.styles.String()) if err := s.Close(); err != nil { return SVGResources{}, err