feat(编辑创建): 支持图片建页
build.yaml / build (push) Successful in 43s

This commit is contained in:
xiaoqidun committed 2026-09-29 11:58:08 +08:00
1 parent 2a93334171
commit cef4756217
8 files changed
+567 -36

No files matched your search

+8 -3
View File
@@ -164,7 +164,7 @@
<span>擦除</span> <span>擦除</span>
</div> </div>
<div class="editor-group"> <div class="editor-group">
<select id="addPageButton" aria-label="新增页面" title="新增页面"><option value="" hidden>新增</option><option value="blank">空白</option><option value="import">插页</option></select> <select id="addPageButton" aria-label="新增页面" title="新增页面"><option value="" hidden>新增</option><option value="blank">空白</option><option value="import">插页</option><option value="images">图片</option></select>
<button id="copyPageButton" class="button" type="button" aria-label="复制页面">复制</button> <button id="copyPageButton" class="button" type="button" aria-label="复制页面">复制</button>
<button id="deletePageButton" class="button" type="button" aria-label="删除页面">删除</button> <button id="deletePageButton" class="button" type="button" aria-label="删除页面">删除</button>
<button id="movePagePrevButton" class="button" type="button" aria-label="页面前移">前移</button> <button id="movePagePrevButton" class="button" type="button" aria-label="页面前移">前移</button>
@@ -284,11 +284,14 @@
</dialog> </dialog>
<dialog id="createPanel" class="form-dialog" aria-label="新建文档"> <dialog id="createPanel" class="form-dialog" aria-label="新建文档">
<form id="createForm"> <form id="createForm">
<div id="createModeRow" class="form-row"><label for="createMode">来源</label><select id="createMode"><option value="blank">空白</option><option value="images">图片</option></select></div>
<div class="form-row"> <div class="form-row">
<label for="createName">名称</label> <label for="createName">名称</label>
<input id="createName" type="text" value="未命名" required autofocus> <input id="createName" type="text" value="未命名" required autofocus>
</div> </div>
<div class="form-pair"> <div id="createImagesRow" class="form-row" hidden><label for="createImages">图片</label><input id="createImages" type="file" accept=".png,.jpg,.jpeg,.jb2,.jbig2" multiple disabled></div>
<div id="createSizeRow" class="form-row" hidden><label for="createSize">尺寸</label><select id="createSize" disabled><option value="image">随图</option><option value="custom">自定</option></select></div>
<div id="createDimensions" class="form-pair">
<div class="form-row"> <div class="form-row">
<label for="createWidth">宽度</label> <label for="createWidth">宽度</label>
<div class="form-unit"><input id="createWidth" type="number" value="210" min="1" step="any" required><span>mm</span></div> <div class="form-unit"><input id="createWidth" type="number" value="210" min="1" step="any" required><span>mm</span></div>
@@ -298,10 +301,12 @@
<div class="form-unit"><input id="createHeight" type="number" value="297" min="1" step="any" required><span>mm</span></div> <div class="form-unit"><input id="createHeight" type="number" value="297" min="1" step="any" required><span>mm</span></div>
</div> </div>
</div> </div>
<div id="createMarginRow" class="form-row" hidden><label for="createMargin">边距</label><div class="form-unit"><input id="createMargin" type="number" value="0" min="0" step="any" required disabled><span>mm</span></div></div>
<div id="createPositionRow" class="form-row" hidden><label for="createPosition">位置</label><select id="createPosition" disabled><option value="before">页前</option><option value="after" selected>页后</option><option value="first">开头</option><option value="last">末尾</option></select></div>
<div class="form-actions"> <div class="form-actions">
<div id="createStatus" class="form-status" role="status"></div> <div id="createStatus" class="form-status" role="status"></div>
<button id="createCancel" class="button" type="button">取消</button> <button id="createCancel" class="button" type="button">取消</button>
<button class="button" type="submit">新建</button> <button id="createSubmit" class="button" type="submit">新建</button>
</div> </div>
</form> </form>
</dialog> </dialog>
+90 -8
View File
@@ -55,6 +55,8 @@ const state = {
importPageCount: 0, importPageCount: 0,
importEncrypted: false, importEncrypted: false,
importing: false, importing: false,
imageImporting: false,
createInsert: false,
pageSelection: new Set(), pageSelection: new Set(),
pageSelectionAnchor: null, pageSelectionAnchor: null,
pageMultiSelect: false, pageMultiSelect: false,
@@ -359,6 +361,12 @@ const el = {
pageCancel: document.querySelector("#pageCancel"), pageCancel: document.querySelector("#pageCancel"),
saveButton: document.querySelector("#saveButton"), saveButton: document.querySelector("#saveButton"),
createPanel: document.querySelector("#createPanel"), 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"), createForm: document.querySelector("#createForm"),
createName: document.querySelector("#createName"), createName: document.querySelector("#createName"),
createWidth: document.querySelector("#createWidth"), createWidth: document.querySelector("#createWidth"),
@@ -886,6 +894,7 @@ el.addPageButton.addEventListener("change", async () => {
if (canvasEditor.input && !await canvasEditor.commitText()) return; if (canvasEditor.input && !await canvasEditor.commitText()) return;
if (canvasEditor.crop && !await canvasEditor.commitCrop()) return; if (canvasEditor.crop && !await canvasEditor.commitCrop()) return;
if (action === "import") { openImportPanel(); return; } if (action === "import") { openImportPanel(); return; }
if (action === "images") { openCreatePanel(true); return; }
const page = currentPageInfo(); const page = currentPageInfo();
return changeDocument("ofdgoChangePage", null, "add", state.pageIndex, page.width, page.height); 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) => { el.editButton.addEventListener("pointerdown", (event) => {
if (canvasEditor.input) event.preventDefault(); 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); el.createForm.addEventListener("submit", createDocument);
editorClick(el.insertTextButton, toggleTextTool); editorClick(el.insertTextButton, toggleTextTool);
editorClick(el.insertImageButton, () => openInsertPanel()); editorClick(el.insertImageButton, () => openInsertPanel());
@@ -1987,30 +2002,75 @@ function confirmTextReflow(item) {
|| window.confirm("将重新排版原文,是否继续?")); || window.confirm("将重新排版原文,是否继续?"));
} }
function openCreatePanel() { function openCreatePanel(insert = false) {
if (document.body.hasAttribute("aria-busy")) { if (document.body.hasAttribute("aria-busy")) {
return; return;
} }
el.createForm.reset(); 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.createStatus.textContent = "";
el.createPanel.showModal(); 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) { async function createDocument(event) {
event.preventDefault(); 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()) { if (document.body.hasAttribute("aria-busy") || !discardChanges()) {
return; return;
} }
const title = el.createName.value.trim() || "未命名"; const title = el.createName.value.trim() || "未命名";
clearSecurityForms(); clearSecurityForms();
const openSeq = ++state.openSeq; const openSeq = ++state.openSeq;
const images = el.createMode.value === "images";
state.imageImporting = images;
setBusy(true, "正在新建文档", null, "正在新建文档"); setBusy(true, "正在新建文档", null, "正在新建文档");
try { try {
await ensureWASM(); await ensureWASM();
if (openSeq !== state.openSeq) { if (openSeq !== state.openSeq) {
return; 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) { if (openSeq !== state.openSeq) {
return; return;
} }
@@ -2039,6 +2099,12 @@ async function createDocument(event) {
el.createStatus.textContent = err.message; el.createStatus.textContent = err.message;
} }
} finally { } finally {
if (images) {
state.imageImporting = false;
state.exportRequestID = 0;
el.cancelExportButton.hidden = true;
el.createCancel.disabled = false;
}
if (openSeq === state.openSeq) { if (openSeq === state.openSeq) {
setBusy(false); setBusy(false);
updateControls(); updateControls();
@@ -2731,7 +2797,8 @@ async function changeDocument(name, item, ...args) {
const before = editorLocation(), revision = state.editorInfo.revision; const before = editorLocation(), revision = state.editorInfo.revision;
const restoring = name === "ofdgoUndo" || name === "ofdgoRedo"; const restoring = name === "ofdgoUndo" || name === "ofdgoRedo";
const { scrollLeft, scrollTop } = el.viewerPanel; const { scrollLeft, scrollTop } = el.viewerPanel;
const importing = name === "ofdgoImportPages"; const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages";
state.imageImporting = name === "ofdgoImportImages";
if (importing) state.importing = true; if (importing) state.importing = true;
setBusy(true, importing ? "正在导入" : "", null); setBusy(true, importing ? "正在导入" : "", null);
try { try {
@@ -2847,6 +2914,8 @@ async function changeDocument(name, item, ...args) {
el.pageStatus.textContent = err.message; el.pageStatus.textContent = err.message;
} else if (el.paragraphPanel.open) { } else if (el.paragraphPanel.open) {
el.paragraphStatus.textContent = err.message; el.paragraphStatus.textContent = err.message;
} else if (el.createPanel.open) {
el.createStatus.textContent = err.message;
} else if (el.importPanel.open) { } else if (el.importPanel.open) {
el.importStatus.textContent = err.message; el.importStatus.textContent = err.message;
} else { } else {
@@ -2856,6 +2925,8 @@ async function changeDocument(name, item, ...args) {
} finally { } finally {
if (importing) { if (importing) {
state.importing = false; state.importing = false;
state.imageImporting = false;
el.createCancel.disabled = false;
state.exportRequestID = 0; state.exportRequestID = 0;
el.cancelExportButton.hidden = true; el.cancelExportButton.hidden = true;
el.importCancel.disabled = false; el.importCancel.disabled = false;
@@ -3299,6 +3370,13 @@ async function loadWASM() {
} }
} }
} else if (data.type === "import") { } 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) { if (wasmRequests.get(data.id)?.openSeq === state.openSeq && state.importing && !el.importCancel.disabled) {
const label = { ids: "正在检查", pages: "正在导入", resources: "正在读取", commit: "正在完成" }[data.phase]; const label = { ids: "正在检查", pages: "正在导入", resources: "正在读取", commit: "正在完成" }[data.phase];
el.importStatus.textContent = data.total ? `${label} ${data.completed} / ${data.total} 页` : label; el.importStatus.textContent = data.total ? `${label} ${data.completed} / ${data.total} 页` : label;
@@ -5100,7 +5178,10 @@ function cancelExport() {
return; return;
} }
el.cancelExportButton.disabled = true; 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.importCancel.disabled = true;
el.importStatus.textContent = "正在取消"; el.importStatus.textContent = "正在取消";
} }
@@ -7991,11 +8072,11 @@ async function callWASM(name, ...args) {
request.fontData = new Map(); request.fontData = new Map();
args.push([...request.localFonts.keys()]); 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; state.exportRequestID = id;
el.cancelExportButton.hidden = false; el.cancelExportButton.hidden = false;
el.cancelExportButton.disabled = 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); el.cancelExportButton.setAttribute("aria-label", el.cancelExportButton.title);
} }
try { try {
@@ -8026,7 +8107,8 @@ function setBusy(busy, text = "", percent = 0, status = "") {
renderSecurity(); renderSecurity();
updateDisplayControls(); updateDisplayControls();
el.editButton.disabled = busy || Boolean(sealPreview) || !state.doc || !state.ready || state.exporting; 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.insertForm.inert = busy;
el.pageForm.inert = busy; el.pageForm.inert = busy;
el.paragraphForm.inert = busy; el.paragraphForm.inert = busy;
+10 -2
View File
@@ -29,7 +29,7 @@ self.onmessage = ({ data }) => {
operations.get(data.id)?.abort(); operations.get(data.id)?.abort();
return; 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()); operations.set(data.id, new AbortController());
} }
pending = pending.then(() => data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" ? handleBatchMessage(data) : handleMessage(data)); 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 output;
let channel; let channel;
const signal = operations.get(id)?.signal; const signal = operations.get(id)?.signal;
const importing = name === "ofdgoImportPages"; const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages";
const converting = name === "ofdgoConvertPDF"; const converting = name === "ofdgoConvertPDF";
const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveEncrypted" || name === "ofdgoSaveSigned"; const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveEncrypted" || name === "ofdgoSaveSigned";
const key = name === "ofdgoOpen" ? args[3]?.key : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.key : null; 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; const password = converting ? args[2] : name === "ofdgoOpen" ? args[3]?.password : name === "ofdgoSaveEncrypted" || name === "ofdgoLoadImport" ? args[1]?.password : null;
try { try {
signal?.throwIfAborted(); 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 = []; const chunks = [];
let size = 0; let size = 0;
if (signal) { if (signal) {
+43
View File
@@ -210,6 +210,7 @@ func RunWASM() {
registerCallback("ofdgoFontFaces", fontFaces) registerCallback("ofdgoFontFaces", fontFaces)
registerCallback("ofdgoFontFace", fontFace) registerCallback("ofdgoFontFace", fontFace)
registerCallback("ofdgoCreateDocument", createDocument) registerCallback("ofdgoCreateDocument", createDocument)
registerAsyncCallback("ofdgoImportImages", importImages)
registerCallback("ofdgoEditDocument", editDocument) registerCallback("ofdgoEditDocument", editDocument)
registerCallback("ofdgoUpdateInfo", updateInfo) registerCallback("ofdgoUpdateInfo", updateInfo)
registerCallback("ofdgoChangePage", changePage) registerCallback("ofdgoChangePage", changePage)
@@ -3528,6 +3529,48 @@ func importPages(args []js.Value) (any, error) {
return editorPageInfo{info, at}, err 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 新建单页文档 // createDocument 新建单页文档
// 入参: args 标题、纸张宽高和注解设置 // 入参: args 标题、纸张宽高和注解设置
// 返回: any 文档信息, error 错误信息 // 返回: any 文档信息, error 错误信息
+173
View File
@@ -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}
}
}
+189
View File
@@ -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
}
+6 -1
View File
@@ -48,9 +48,14 @@ func (CanvasBackend) RenderSVG(r *Renderer, page *PageContent, writer io.Writer,
return SVGResources{}, err return SVGResources{}, err
} }
buffer := bufio.NewWriter(writer) 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, svgCreatorMetadata)
io.WriteString(buffer, `<g style="image-orientation:none">`)
c.RenderTo(renderer) c.RenderTo(renderer)
if renderer.err != nil {
return SVGResources{}, renderer.err
}
io.WriteString(buffer, `</g>`)
if err := renderer.Close(); err != nil { if err := renderer.Close(); err != nil {
return SVGResources{}, err return SVGResources{}, err
} }
+48 -22
View File
@@ -18,6 +18,7 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"crypto/sha256" "crypto/sha256"
"encoding/base64"
"fmt" "fmt"
"html" "html"
"image" "image"
@@ -36,16 +37,17 @@ const svgCreatorMetadata = `<metadata>` + creatorXMP + `</metadata>`
// svgResourceRenderer 分离外部资源的SVG渲染器 // svgResourceRenderer 分离外部资源的SVG渲染器
type svgResourceRenderer struct { type svgResourceRenderer struct {
*svg.SVG *svg.SVG
renderer *Renderer renderer *Renderer
writer io.Writer writer io.Writer
fonts []SVGFont fonts []SVGFont
images []SVGImage images []SVGImage
imageNames map[image.Image]string imageNames map[image.Image]string
seen map[string]bool seen map[string]bool
styles strings.Builder styles strings.Builder
err error err error
objects map[*GraphicObject]string objects map[*GraphicObject]string
objectStack []svgObjectGroup objectStack []svgObjectGroup
embeddedFonts bool
} }
// svgObjectGroup 记录编辑分组的稳定路径,不把底纹内部绘制当作成员 // svgObjectGroup 记录编辑分组的稳定路径,不把底纹内部绘制当作成员
@@ -58,13 +60,10 @@ type svgObjectGroup struct {
// RenderImage 绘制图片,分离资源时保持原始编码、尺寸和变换 // RenderImage 绘制图片,分离资源时保持原始编码、尺寸和变换
// 入参: img 图片对象, m 变换矩阵 // 入参: img 图片对象, m 变换矩阵
func (s *svgResourceRenderer) RenderImage(img image.Image, m canvas.Matrix) { 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] name, ok := s.imageNames[img]
var resource SVGImage
if !ok { if !ok {
resource := SVGImage{MIME: "image/png"} resource = SVGImage{MIME: "image/png"}
if encoded, ok := img.(*canvasimage.Image); ok { if encoded, ok := img.(*canvasimage.Image); ok {
resource.MIME, resource.Data = encoded.Mimetype, encoded.Bytes resource.MIME, resource.Data = encoded.Mimetype, encoded.Bytes
} else { } else {
@@ -75,22 +74,46 @@ func (s *svgResourceRenderer) RenderImage(img image.Image, m canvas.Matrix) {
} }
resource.Data = buffer.Bytes() resource.Data = buffer.Bytes()
} }
name = fmt.Sprintf("ofdgo-image-%x", sha256.Sum256(resource.Data)) if s.imageNames != nil {
resource.Name = name name = fmt.Sprintf("ofdgo-image-%x", sha256.Sum256(resource.Data))
s.imageNames[img] = name resource.Name = name
if !s.seen[name] { s.imageNames[img] = name
s.seen[name] = true if !s.seen[name] {
s.images = append(s.images, resource) s.seen[name] = true
s.images = append(s.images, resource)
}
} }
} }
_, height := s.Size() _, height := s.Size()
size := img.Bounds().Size() size := img.Bounds().Size()
fmt.Fprintf(s.writer, `<image transform="%s" width="%d" height="%d" xlink:href="%s"/>`, 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, `<image transform="matrix(%s)" width="%d" height="%d" xlink:href="`, matrix.String(), size.X, size.Y)
if s.imageNames != nil {
io.WriteString(s.writer, name)
} else {
fmt.Fprintf(s.writer, "data:%s;base64,", resource.MIME)
encoder := base64.NewEncoder(base64.StdEncoding, s.writer)
if _, err := encoder.Write(resource.Data); err != nil {
s.err = err
return
}
if err := encoder.Close(); err != nil {
s.err = err
return
}
}
io.WriteString(s.writer, `"/>`)
} }
// RenderText 绘制文字并记录实际使用的字体资源 // RenderText 绘制文字并记录实际使用的字体资源
// 入参: text 文字对象, m 变换矩阵 // 入参: text 文字对象, m 变换矩阵
func (s *svgResourceRenderer) RenderText(text *canvas.Text, m canvas.Matrix) { func (s *svgResourceRenderer) RenderText(text *canvas.Text, m canvas.Matrix) {
if s.embeddedFonts {
s.SVG.RenderText(text, m)
return
}
if text.Empty() { if text.Empty() {
return return
} }
@@ -137,6 +160,8 @@ func (r *Renderer) renderSVGResources(page *PageContent, writer io.Writer, image
seen: make(map[string]bool), seen: make(map[string]bool),
} }
io.WriteString(buffer, svgCreatorMetadata) io.WriteString(buffer, svgCreatorMetadata)
// 图片方向由文档变换决定,不再由浏览器应用EXIF方向。
io.WriteString(buffer, `<g style="image-orientation:none">`)
if images { if images {
s.imageNames = make(map[image.Image]string) 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 { if s.err != nil {
return SVGResources{}, s.err return SVGResources{}, s.err
} }
io.WriteString(buffer, `</g>`)
s.SetCustomStyle(s.styles.String()) s.SetCustomStyle(s.styles.String())
if err := s.Close(); err != nil { if err := s.Close(); err != nil {
return SVGResources{}, err return SVGResources{}, err