+
+
+
+
+
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