mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 17:27:08 +08:00
1 parent
2a93334171
commit
cef4756217
8 files changed
+567
-36
No files matched your search
@@ -164,7 +164,7 @@
|
||||
<span>擦除</span>
|
||||
</div>
|
||||
<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="deletePageButton" class="button" type="button" aria-label="删除页面">删除</button>
|
||||
<button id="movePagePrevButton" class="button" type="button" aria-label="页面前移">前移</button>
|
||||
@@ -284,11 +284,14 @@
|
||||
</dialog>
|
||||
<dialog id="createPanel" class="form-dialog" aria-label="新建文档">
|
||||
<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">
|
||||
<label for="createName">名称</label>
|
||||
<input id="createName" type="text" value="未命名" required autofocus>
|
||||
</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">
|
||||
<label for="createWidth">宽度</label>
|
||||
<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>
|
||||
</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 id="createStatus" class="form-status" role="status"></div>
|
||||
<button id="createCancel" class="button" type="button">取消</button>
|
||||
<button class="button" type="submit">新建</button>
|
||||
<button id="createSubmit" class="button" type="submit">新建</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
+90
-8
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 错误信息
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, `<g style="image-orientation:none">`)
|
||||
c.RenderTo(renderer)
|
||||
if renderer.err != nil {
|
||||
return SVGResources{}, renderer.err
|
||||
}
|
||||
io.WriteString(buffer, `</g>`)
|
||||
if err := renderer.Close(); err != nil {
|
||||
return SVGResources{}, err
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html"
|
||||
"image"
|
||||
@@ -36,16 +37,17 @@ const svgCreatorMetadata = `<metadata>` + creatorXMP + `</metadata>`
|
||||
// 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, `<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 绘制文字并记录实际使用的字体资源
|
||||
// 入参: 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, `<g style="image-orientation:none">`)
|
||||
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, `</g>`)
|
||||
s.SetCustomStyle(s.styles.String())
|
||||
if err := s.Close(); err != nil {
|
||||
return SVGResources{}, err
|
||||
|
||||
Reference in new issue
Block a user