mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-30 01:27:26 +08:00
1 parent
0ad2bda1f7
commit
5f790e5408
8 files changed
+1173
-17
No files matched your search
@@ -185,6 +185,7 @@
|
|||||||
<div class="convert-options">
|
<div class="convert-options">
|
||||||
<div class="form-row"><label for="convertFormat">格式</label><select id="convertFormat" required></select></div>
|
<div class="form-row"><label for="convertFormat">格式</label><select id="convertFormat" required></select></div>
|
||||||
<div class="form-row"><label for="convertCompression">压缩</label><select id="convertCompression"><option value="0">默认</option><option value="1">无损</option><option value="2">轻压</option><option value="3">均衡</option><option value="4">强压</option></select></div>
|
<div class="form-row"><label for="convertCompression">压缩</label><select id="convertCompression"><option value="0">默认</option><option value="1">无损</option><option value="2">轻压</option><option value="3">均衡</option><option value="4">强压</option></select></div>
|
||||||
|
<div id="convertFontRow" class="form-row" hidden><label for="convertFontMode">字体</label><select id="convertFontMode"><option value="0">保持</option><option value="1">内嵌</option><option value="2">外部</option></select></div>
|
||||||
<div id="convertDPIRow" class="form-row" hidden><label for="convertDPI">精度</label><select id="convertDPI"><option value="96">96 dpi</option><option value="150">150 dpi</option><option value="300" selected>300 dpi</option><option value="600">600 dpi</option></select></div>
|
<div id="convertDPIRow" class="form-row" hidden><label for="convertDPI">精度</label><select id="convertDPI"><option value="96">96 dpi</option><option value="150">150 dpi</option><option value="300" selected>300 dpi</option><option value="600">600 dpi</option></select></div>
|
||||||
<div class="form-row"><label for="convertDestination">保存</label><select id="convertDestination"><option value="directory">目录</option><option value="archive">打包</option></select></div>
|
<div class="form-row"><label for="convertDestination">保存</label><select id="convertDestination"><option value="directory">目录</option><option value="archive">打包</option></select></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -434,6 +435,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
<dialog id="fontBindingPanel" class="form-dialog" aria-labelledby="fontBindingTitle">
|
||||||
|
<form id="fontBindingForm">
|
||||||
|
<h2 id="fontBindingTitle" class="form-title">嵌入字体</h2>
|
||||||
|
<div class="form-row"><span>字体</span><output id="fontBindingName"></output></div>
|
||||||
|
<div id="fontBindingSourceRow" class="form-row"><label for="fontBindingSource">来源</label><select id="fontBindingSource" required></select></div>
|
||||||
|
<input id="fontBindingFile" type="file" accept=".ttf,.otf,.ttc,.otc" hidden>
|
||||||
|
<div id="fontBindingHint" class="form-status">外置后,其他设备需具备此字体</div>
|
||||||
|
<div class="form-actions"><div id="fontBindingStatus" class="form-status" role="status"></div><button id="fontBindingCancel" class="button" type="button">取消</button><button id="fontBindingSubmit" class="button" type="submit">嵌入</button></div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
<dialog id="insertPanel" class="form-dialog" aria-label="添加图片">
|
<dialog id="insertPanel" class="form-dialog" aria-label="添加图片">
|
||||||
<form id="insertForm">
|
<form id="insertForm">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
|||||||
+55
-2
@@ -731,10 +731,14 @@ button:not(:disabled):hover {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.convert-options:has(#convertDPIRow[hidden]) {
|
.convert-options:has(#convertDPIRow[hidden]):has(#convertFontRow[hidden]) {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.convert-options:has(#convertDPIRow:not([hidden])):has(#convertFontRow:not([hidden])) {
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.convert-options .form-row {
|
.convert-options .form-row {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
@@ -974,9 +978,15 @@ button:not(:disabled):hover {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.convert-options:has(#convertDPIRow[hidden]) {
|
.convert-options:has(#convertDPIRow[hidden]):has(#convertFontRow[hidden]) {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
.convert-options:has(#convertDPIRow:not([hidden])):has(#convertFontRow:not([hidden])) {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.convert-options:has(#convertDPIRow:not([hidden])):has(#convertFontRow:not([hidden])) .form-row:last-child {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
.convert-options .form-row {
|
.convert-options .form-row {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -2816,6 +2826,39 @@ audio.page-media {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.doc-font-head:has(.font-binding-action) {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-font-head:has(.font-binding-action) .doc-font-name {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-font-head:has(.font-binding-action) .doc-font-badges {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-binding-action {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fontBindingName {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fontBindingHint {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.info-name-button {
|
.info-name-button {
|
||||||
display: inline;
|
display: inline;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -2882,6 +2925,16 @@ audio.page-media {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.doc-font-head .font-badge,
|
||||||
|
.font-binding-action {
|
||||||
|
width: 36px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
.font-badge.embedded,
|
.font-badge.embedded,
|
||||||
.font-badge.matched,
|
.font-badge.matched,
|
||||||
.font-badge.valid {
|
.font-badge.valid {
|
||||||
|
|||||||
+161
-10
@@ -20,7 +20,9 @@ const STATUS = {
|
|||||||
const wasmRequests = new Map();
|
const wasmRequests = new Map();
|
||||||
const metaContents = new WeakMap();
|
const metaContents = new WeakMap();
|
||||||
const batch = { items: [], formats: [], running: false, canceled: false, worker: null, requests: new Map(), sequence: 0, activeID: 0, progressTime: 0 };
|
const batch = { items: [], formats: [], running: false, canceled: false, worker: null, requests: new Map(), sequence: 0, activeID: 0, progressTime: 0 };
|
||||||
const batchElements = Object.fromEntries(["Button", "Panel", "Form", "Input", "Add", "Clear", "Count", "Format", "Compression", "Destination", "DPIRow", "DPI", "List", "Empty", "Progress", "Status", "Close", "Cancel", "Start"].map(name => [name, document.querySelector(`#convert${name}`)]));
|
const batchElements = Object.fromEntries(["Button", "Panel", "Form", "Input", "Add", "Clear", "Count", "Format", "Compression", "FontRow", "FontMode", "Destination", "DPIRow", "DPI", "List", "Empty", "Progress", "Status", "Close", "Cancel", "Start"].map(name => [name, document.querySelector(`#convert${name}`)]));
|
||||||
|
const fontBinding = { font: null, choices: [], sequence: 0 };
|
||||||
|
const fontBindingElements = Object.fromEntries(["Panel", "Form", "Title", "Name", "SourceRow", "Source", "File", "Hint", "Status", "Cancel", "Submit"].map(name => [name, document.querySelector(`#fontBinding${name}`)]));
|
||||||
|
|
||||||
let wasmPromise = null;
|
let wasmPromise = null;
|
||||||
let remoteDownload = null;
|
let remoteDownload = null;
|
||||||
@@ -1201,6 +1203,46 @@ batchElements.Panel.addEventListener("dragover", event => { event.preventDefault
|
|||||||
batchElements.Panel.addEventListener("drop", event => { event.preventDefault(); event.stopPropagation(); if (!batch.running) addBatchFiles(event.dataTransfer.files); });
|
batchElements.Panel.addEventListener("drop", event => { event.preventDefault(); event.stopPropagation(); if (!batch.running) addBatchFiles(event.dataTransfer.files); });
|
||||||
batchElements.Destination.querySelector('[value="directory"]').disabled = !window.showDirectoryPicker;
|
batchElements.Destination.querySelector('[value="directory"]').disabled = !window.showDirectoryPicker;
|
||||||
batchElements.Destination.value = window.showDirectoryPicker ? "directory" : "archive";
|
batchElements.Destination.value = window.showDirectoryPicker ? "directory" : "archive";
|
||||||
|
fontBindingElements.Cancel.addEventListener("click", () => fontBindingElements.Panel.close());
|
||||||
|
fontBindingElements.Panel.addEventListener("close", () => { fontBinding.font = null; fontBinding.choices = []; fontBinding.sequence++; fontBindingElements.File.value = ""; });
|
||||||
|
fontBindingElements.Source.addEventListener("change", () => {
|
||||||
|
if (fontBindingElements.Source.value === "file") { fontBindingElements.Source.value = ""; fontBindingElements.File.click(); }
|
||||||
|
});
|
||||||
|
fontBindingElements.File.addEventListener("change", async () => {
|
||||||
|
const file = fontBindingElements.File.files[0], openSeq = state.openSeq;
|
||||||
|
if (!file) return;
|
||||||
|
const sequence = ++fontBinding.sequence;
|
||||||
|
fontBindingElements.File.value = "";
|
||||||
|
try {
|
||||||
|
const data = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const faces = await callWASM("ofdgoFontFaces", data);
|
||||||
|
if (sequence !== fontBinding.sequence || openSeq !== state.openSeq) return;
|
||||||
|
for (const face of faces) {
|
||||||
|
const value = String(fontBinding.choices.length);
|
||||||
|
fontBinding.choices.push({ data, index: face.index });
|
||||||
|
fontBindingElements.Source.add(new Option(face.fullName || face.family || file.name, value));
|
||||||
|
if (faces.length === 1) fontBindingElements.Source.value = value;
|
||||||
|
}
|
||||||
|
fontBindingElements.Status.textContent = faces.length > 1 ? "请选择字体" : "";
|
||||||
|
} catch (err) { if (sequence === fontBinding.sequence) fontBindingElements.Status.textContent = err.message; }
|
||||||
|
});
|
||||||
|
fontBindingElements.Form.addEventListener("submit", async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const font = fontBinding.font, sequence = fontBinding.sequence, openSeq = state.openSeq;
|
||||||
|
if (!font || document.body.hasAttribute("aria-busy") || !fontBindingElements.Form.reportValidity()) return;
|
||||||
|
fontBindingElements.Form.inert = true;
|
||||||
|
try {
|
||||||
|
let data = null;
|
||||||
|
if (!font.embedded) {
|
||||||
|
const choice = fontBinding.choices[Number(fontBindingElements.Source.value)];
|
||||||
|
if (!choice) throw new Error("请选择字体");
|
||||||
|
data = choice.data ? (await callWASM("ofdgoFontFace", choice.data, choice.index || 0)).bytes : await readTextFont(choice);
|
||||||
|
}
|
||||||
|
if (sequence !== fontBinding.sequence || openSeq !== state.openSeq) return;
|
||||||
|
if (await changeDocument("ofdgoChangeFontBinding", null, font.id, font.embedded ? 2 : 1, data)) fontBindingElements.Panel.close();
|
||||||
|
} catch (err) { if (sequence === fontBinding.sequence) fontBindingElements.Status.textContent = fontBindingError(err.message); }
|
||||||
|
finally { if (sequence === fontBinding.sequence) fontBindingElements.Form.inert = false; }
|
||||||
|
});
|
||||||
el.newButton.addEventListener("click", openCreatePanel);
|
el.newButton.addEventListener("click", openCreatePanel);
|
||||||
el.editButton.addEventListener("click", toggleEditor);
|
el.editButton.addEventListener("click", toggleEditor);
|
||||||
el.editButton.addEventListener("pointerdown", (event) => {
|
el.editButton.addEventListener("pointerdown", (event) => {
|
||||||
@@ -1567,7 +1609,7 @@ function handleKeyDown(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formDialogOpen() {
|
function formDialogOpen() {
|
||||||
return Boolean(sealPreview) || el.signPanel.open || el.verifyPanel.open || el.credentialsPanel.open || el.encryptionPanel.open || batchElements.Panel.open || el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open || el.documentPanel.open
|
return Boolean(sealPreview) || fontBindingElements.Panel.open || el.signPanel.open || el.verifyPanel.open || el.credentialsPanel.open || el.encryptionPanel.open || batchElements.Panel.open || el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open || el.documentPanel.open
|
||||||
|| el.batchPagesPanel.open || el.objectStylePanel.open || el.sourceTextPanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open || el.annotationCreate.open || el.objectPicker.open;
|
|| el.batchPagesPanel.open || el.objectStylePanel.open || el.sourceTextPanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open || el.annotationCreate.open || el.objectPicker.open;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2141,6 +2183,7 @@ async function toggleEditor() {
|
|||||||
if (!await canvasEditor.commitText() || !await canvasEditor.commitCrop()) return;
|
if (!await canvasEditor.commitText() || !await canvasEditor.commitCrop()) return;
|
||||||
const anchor = scaleAnchor(0);
|
const anchor = scaleAnchor(0);
|
||||||
state.editing = !state.editing;
|
state.editing = !state.editing;
|
||||||
|
renderDocumentFonts();
|
||||||
resetCompositeScope();
|
resetCompositeScope();
|
||||||
canvasEditor.clear();
|
canvasEditor.clear();
|
||||||
canvasEditor.setTool("");
|
canvasEditor.setTool("");
|
||||||
@@ -2156,6 +2199,7 @@ async function toggleEditor() {
|
|||||||
await refreshPageDisplay(anchor);
|
await refreshPageDisplay(anchor);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
state.editing = !state.editing;
|
state.editing = !state.editing;
|
||||||
|
renderDocumentFonts();
|
||||||
updateControls();
|
updateControls();
|
||||||
showError(err, false);
|
showError(err, false);
|
||||||
}
|
}
|
||||||
@@ -3010,10 +3054,17 @@ async function changeDocument(name, item, ...args) {
|
|||||||
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" || name === "ofdgoImportImages";
|
const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages";
|
||||||
|
const changingFont = name === "ofdgoChangeFontBinding";
|
||||||
let refreshManagement = false;
|
let refreshManagement = false;
|
||||||
state.imageImporting = name === "ofdgoImportImages";
|
state.imageImporting = name === "ofdgoImportImages";
|
||||||
if (importing) state.importing = true;
|
if (importing) state.importing = true;
|
||||||
setBusy(true, importing ? "正在导入页面" : "", null);
|
setBusy(true, importing ? "正在导入页面" : "", null);
|
||||||
|
const progressTimer = changingFont ? window.setTimeout(() => {
|
||||||
|
if (openSeq === state.openSeq && document.body.hasAttribute("aria-busy")) {
|
||||||
|
el.progressPanel.hidden = false;
|
||||||
|
setProgress("正在处理字体", null);
|
||||||
|
}
|
||||||
|
}, 250) : null;
|
||||||
try {
|
try {
|
||||||
args = await Promise.all(args);
|
args = await Promise.all(args);
|
||||||
if (openSeq !== state.openSeq) {
|
if (openSeq !== state.openSeq) {
|
||||||
@@ -3046,7 +3097,9 @@ async function changeDocument(name, item, ...args) {
|
|||||||
refreshManagement = el.documentPanel.open && (name === "ofdgoChangeDocument" || name === "ofdgoUpdateInfo" || restoring);
|
refreshManagement = el.documentPanel.open && (name === "ofdgoChangeDocument" || name === "ofdgoUpdateInfo" || restoring);
|
||||||
const previousDocument = state.doc.documents?.[state.doc.documentIndex || 0];
|
const previousDocument = state.doc.documents?.[state.doc.documentIndex || 0];
|
||||||
const nextDocument = doc.documents?.[doc.documentIndex || 0];
|
const nextDocument = doc.documents?.[doc.documentIndex || 0];
|
||||||
const documentChanged = previousDocument?.root !== nextDocument?.root || previousDocument?.id !== nextDocument?.id;
|
const sameDocument = previousDocument?.id && previousDocument.id === nextDocument?.id && state.doc.documentIndex === doc.documentIndex;
|
||||||
|
const documentChanged = previousDocument?.id !== nextDocument?.id || previousDocument?.root !== nextDocument?.root
|
||||||
|
&& !(sameDocument && (name === "ofdgoChangeFontBinding" || restoring));
|
||||||
const packageChanged = documentChanged || name === "ofdgoChangeDocument" || restoring
|
const packageChanged = documentChanged || name === "ofdgoChangeDocument" || restoring
|
||||||
&& JSON.stringify(doc.documents?.map(entry => [entry.root, entry.id])) !== JSON.stringify(state.doc.documents?.map(entry => [entry.root, entry.id]));
|
&& JSON.stringify(doc.documents?.map(entry => [entry.root, entry.id])) !== JSON.stringify(state.doc.documents?.map(entry => [entry.root, entry.id]));
|
||||||
if (packageChanged) {
|
if (packageChanged) {
|
||||||
@@ -3123,7 +3176,9 @@ async function changeDocument(name, item, ...args) {
|
|||||||
}
|
}
|
||||||
if (scoped && name === "ofdgoOrderObjects") canvasEditor.pendingSelection = { index: item.index, ids: doc.selectedIDs };
|
if (scoped && name === "ofdgoOrderObjects") canvasEditor.pendingSelection = { index: item.index, ids: doc.selectedIDs };
|
||||||
openSeq = ++state.openSeq;
|
openSeq = ++state.openSeq;
|
||||||
if (item || name === "ofdgoPasteObjects" || inserting || editingAnnotation) {
|
if (changingFont) {
|
||||||
|
await refreshFontBinding(doc, openSeq);
|
||||||
|
} else if (item || name === "ofdgoPasteObjects" || inserting || editingAnnotation) {
|
||||||
await refreshEditorPage(doc, item ? item.index : args[0], openSeq, clearSelection);
|
await refreshEditorPage(doc, item ? item.index : args[0], openSeq, clearSelection);
|
||||||
} else {
|
} else {
|
||||||
const samePage = pageIndexByID(doc.pages, previous.id);
|
const samePage = pageIndexByID(doc.pages, previous.id);
|
||||||
@@ -3137,13 +3192,16 @@ async function changeDocument(name, item, ...args) {
|
|||||||
}
|
}
|
||||||
if (openSeq === state.openSeq) {
|
if (openSeq === state.openSeq) {
|
||||||
if (!restoring) rememberEditorView(before, revision, reindex);
|
if (!restoring) rememberEditorView(before, revision, reindex);
|
||||||
if (!toolbarFocus) canvasEditor.focus();
|
if (!toolbarFocus && !changingFont) canvasEditor.focus();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
refreshManagement = false;
|
refreshManagement = false;
|
||||||
|
if (name === "ofdgoChangeFontBinding") err.message = fontBindingError(err.message);
|
||||||
if (openSeq === state.openSeq) {
|
if (openSeq === state.openSeq) {
|
||||||
if (el.annotationCreate.open) {
|
if (fontBindingElements.Panel.open) {
|
||||||
|
fontBindingElements.Status.textContent = fontBindingError(err.message);
|
||||||
|
} else if (el.annotationCreate.open) {
|
||||||
el.annotationCreateStatus.textContent = err.message;
|
el.annotationCreateStatus.textContent = err.message;
|
||||||
} else if (el.annotationNote.open) {
|
} else if (el.annotationNote.open) {
|
||||||
el.annotationStatus.textContent = err.message;
|
el.annotationStatus.textContent = err.message;
|
||||||
@@ -3176,6 +3234,7 @@ async function changeDocument(name, item, ...args) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (progressTimer !== null) window.clearTimeout(progressTimer);
|
||||||
if (importing) {
|
if (importing) {
|
||||||
state.importing = false;
|
state.importing = false;
|
||||||
state.imageImporting = false;
|
state.imageImporting = false;
|
||||||
@@ -3192,6 +3251,52 @@ async function changeDocument(name, item, ...args) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshFontBinding(doc, openSeq) {
|
||||||
|
const indices = new Set([state.pageIndex, ...state.visiblePages, ...state.visibleThumbnails]);
|
||||||
|
for (const shell of el.svgHost.querySelectorAll(".page-shell.rendered")) indices.add(Number(shell.dataset.pageIndex));
|
||||||
|
state.doc = doc;
|
||||||
|
resetSearch();
|
||||||
|
state.documentSelection = null;
|
||||||
|
resetPageLoading();
|
||||||
|
state.pageCache.clear();
|
||||||
|
canvasEditor.clear();
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
const details = await callWASM("ofdgoDocumentInfo");
|
||||||
|
if (openSeq !== state.openSeq) return;
|
||||||
|
Object.assign(doc, details, { detailsPending: false, detailsError: "" });
|
||||||
|
} catch (err) {
|
||||||
|
if (openSeq !== state.openSeq) return;
|
||||||
|
Object.assign(doc, { detailsPending: false, detailsError: err.message });
|
||||||
|
}
|
||||||
|
for (const index of indices) {
|
||||||
|
try {
|
||||||
|
const page = await loadPageData(index, { openSeq, priority: 0, refresh: true });
|
||||||
|
if (openSeq !== state.openSeq) return;
|
||||||
|
if (page) {
|
||||||
|
mountPageSVG(index, page, openSeq);
|
||||||
|
updateThumbnail(index, openSeq);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (openSeq !== state.openSeq) return;
|
||||||
|
unmountPage(index);
|
||||||
|
markFlowPageError(index, err);
|
||||||
|
markThumbnailError(index);
|
||||||
|
showError(err, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (openSeq === state.openSeq) {
|
||||||
|
releasePageResources();
|
||||||
|
renderMeta(true);
|
||||||
|
updateControls();
|
||||||
|
loadDocumentDetails(openSeq);
|
||||||
|
for (const shell of el.svgHost.children) observeFlowPage(shell);
|
||||||
|
for (const button of el.pageList.children) observeThumbnail(button, Number(button.dataset.pageIndex), openSeq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshEditorPage(doc, index, openSeq, clearSelection = false) {
|
async function refreshEditorPage(doc, index, openSeq, clearSelection = false) {
|
||||||
state.doc = doc;
|
state.doc = doc;
|
||||||
resetSearch();
|
resetSearch();
|
||||||
@@ -3860,6 +3965,7 @@ async function openOFD(file, remote = null) {
|
|||||||
setDirty(false);
|
setDirty(false);
|
||||||
el.createPanel.close();
|
el.createPanel.close();
|
||||||
el.insertPanel.close();
|
el.insertPanel.close();
|
||||||
|
fontBindingElements.Panel.close();
|
||||||
el.pagePanel.close();
|
el.pagePanel.close();
|
||||||
el.paragraphPanel.close();
|
el.paragraphPanel.close();
|
||||||
el.infoPanel.close();
|
el.infoPanel.close();
|
||||||
@@ -5239,7 +5345,8 @@ function renderBatchList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateBatchControls() {
|
function updateBatchControls() {
|
||||||
for (const name of ["Add", "Clear", "Format", "Compression", "Destination", "DPI"]) batchElements[name].disabled = batch.running;
|
for (const name of ["Add", "Clear", "Format", "Compression", "FontMode", "Destination", "DPI"]) batchElements[name].disabled = batch.running;
|
||||||
|
batchElements.FontRow.hidden = !(batch.items.length ? batch.items : [null]).some(item => (item?.format || batchElements.Format.value) === "ofd");
|
||||||
batchElements.Compression.disabled ||= (batch.items.length ? batch.items : [null]).every(item => (item?.format || batchElements.Format.value) === "txt");
|
batchElements.Compression.disabled ||= (batch.items.length ? batch.items : [null]).every(item => (item?.format || batchElements.Format.value) === "txt");
|
||||||
batchElements.Clear.disabled ||= !batch.items.length;
|
batchElements.Clear.disabled ||= !batch.items.length;
|
||||||
batchElements.Start.disabled = batch.running || !batch.items.length || !batch.formats.length;
|
batchElements.Start.disabled = batch.running || !batch.items.length || !batch.formats.length;
|
||||||
@@ -5362,7 +5469,7 @@ async function runBatch() {
|
|||||||
const fontKey = [fontManager.permission, fontManager.records().map(font => [font.id, font.name, font.enabled, font.checksum])];
|
const fontKey = [fontManager.permission, fontManager.records().map(font => [font.id, font.name, font.enabled, font.checksum])];
|
||||||
for (const item of batch.items) {
|
for (const item of batch.items) {
|
||||||
const format = batch.formats.find(format => format.value === (item.format || defaultFormat.value));
|
const format = batch.formats.find(format => format.value === (item.format || defaultFormat.value));
|
||||||
const key = JSON.stringify([format.value, pageRange(item), batchUsesDPI(item) ? dpi : 0, archive, backend, fontKey, compression]);
|
const key = JSON.stringify([format.value, pageRange(item), batchUsesDPI(item) ? dpi : 0, archive, backend, fontKey, compression, format.value === "ofd" ? Number(batchElements.FontMode.value) : 0]);
|
||||||
if (item.settingsKey !== key) {
|
if (item.settingsKey !== key) {
|
||||||
item.status = "pending";
|
item.status = "pending";
|
||||||
item.text = "待转";
|
item.text = "待转";
|
||||||
@@ -5396,7 +5503,8 @@ async function runBatch() {
|
|||||||
}
|
}
|
||||||
} else destination = await window.showDirectoryPicker({ mode: "readwrite" });
|
} else destination = await window.showDirectoryPicker({ mode: "readwrite" });
|
||||||
if (batch.canceled) return;
|
if (batch.canceled) return;
|
||||||
if (jobs.some(item => /\.pdf$/i.test(item.file.name) || !["ofd", "txt"].includes(item.format || defaultFormat.value))) {
|
if (jobs.some(item => /\.pdf$/i.test(item.file.name) || !["ofd", "txt"].includes(item.format || defaultFormat.value)
|
||||||
|
|| (item.format || defaultFormat.value) === "ofd" && batchElements.FontMode.value === "1")) {
|
||||||
await requestLocalFontsBeforeOpen();
|
await requestLocalFontsBeforeOpen();
|
||||||
}
|
}
|
||||||
if (batch.canceled) return;
|
if (batch.canceled) return;
|
||||||
@@ -5416,13 +5524,14 @@ async function runBatch() {
|
|||||||
for (let suffix = 2; archive && names.has(outputName()); suffix++) unique = `${base} (${suffix})`;
|
for (let suffix = 2; archive && names.has(outputName()); suffix++) unique = `${base} (${suffix})`;
|
||||||
names.add(outputName());
|
names.add(outputName());
|
||||||
const options = { format: format.value, pages: pageRange(item), dpi, backend, archive, compression, base: unique, credentials: null,
|
const options = { format: format.value, pages: pageRange(item), dpi, backend, archive, compression, base: unique, credentials: null,
|
||||||
fontNames: [...batch.localFonts.keys()] };
|
fontNames: [...batch.localFonts.keys()], fontMode: format.value === "ofd" ? Number(batchElements.FontMode.value) : 0 };
|
||||||
try {
|
try {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
try {
|
try {
|
||||||
const result = await batchCall("ofdgoConvertFile", [item.file, options, fonts, destination], progress => batchProgress(item, position, jobs.length, progress));
|
const result = await batchCall("ofdgoConvertFile", [item.file, options, fonts, destination], progress => batchProgress(item, position, jobs.length, progress));
|
||||||
for (const file of result.files) entries.push(file);
|
for (const file of result.files) entries.push(file);
|
||||||
item.details = (result.pdf?.Warnings || []).map(warning => `${warning.Page ? `第${warning.Page}页:` : ""}${warning.Message}`);
|
item.details = (result.pdf?.Warnings || []).map(warning => `${warning.Page ? `第${warning.Page}页:` : ""}${warning.Message}`);
|
||||||
|
for (const font of result.fonts || []) item.details.push(`${font.name}:${font.changed ? options.fontMode === 1 ? "已嵌入" : "已外置" : `未处理 · ${fontBindingError(font.reason)}`}`);
|
||||||
if (result.unchanged && !result.bytes) {
|
if (result.unchanged && !result.bytes) {
|
||||||
batchStatus(item, "unchanged", "原样");
|
batchStatus(item, "unchanged", "原样");
|
||||||
item.details = ["无需转换,源文件未改动"];
|
item.details = ["无需转换,源文件未改动"];
|
||||||
@@ -8224,6 +8333,16 @@ function renderDocumentFonts() {
|
|||||||
const metaText = fontMeta(font);
|
const metaText = fontMeta(font);
|
||||||
|
|
||||||
head.append(badges, name);
|
head.append(badges, name);
|
||||||
|
if (state.editing) {
|
||||||
|
const action = document.createElement("button");
|
||||||
|
action.type = "button";
|
||||||
|
action.className = "small-button font-binding-action";
|
||||||
|
action.textContent = font.embedded ? "外置" : "嵌入";
|
||||||
|
action.title = font.embedded ? "外置字体" : "嵌入字体";
|
||||||
|
action.setAttribute("aria-label", action.title);
|
||||||
|
action.addEventListener("click", () => openFontBinding(font));
|
||||||
|
head.append(action);
|
||||||
|
}
|
||||||
row.append(head);
|
row.append(head);
|
||||||
appendFontDetail(row, metaText);
|
appendFontDetail(row, metaText);
|
||||||
appendFontDetail(row, resultText);
|
appendFontDetail(row, resultText);
|
||||||
@@ -8232,6 +8351,37 @@ function renderDocumentFonts() {
|
|||||||
el.docFontList.append(fragment);
|
el.docFontList.append(fragment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openFontBinding(font) {
|
||||||
|
if (!state.editing || document.body.hasAttribute("aria-busy")) return;
|
||||||
|
if (!font.embedded && font.status === "matched") {
|
||||||
|
await changeDocument("ofdgoChangeFontBinding", null, font.id, 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fontBinding.font = font;
|
||||||
|
fontBinding.sequence++;
|
||||||
|
fontBindingElements.Form.inert = false;
|
||||||
|
fontBinding.choices = fontManager.editorFonts().filter(choice => !choice.disabled);
|
||||||
|
fontBindingElements.Title.textContent = font.embedded ? "外置字体" : "嵌入字体";
|
||||||
|
fontBindingElements.Name.textContent = font.fontName || font.familyName || font.id;
|
||||||
|
fontBindingElements.SourceRow.hidden = font.embedded;
|
||||||
|
fontBindingElements.Source.disabled = font.embedded;
|
||||||
|
fontBindingElements.Source.replaceChildren(new Option("请选择", ""), ...fontBinding.choices.map((choice, i) => new Option(choice.fullName || choice.name, String(i))), new Option("添加字体", "file"));
|
||||||
|
fontBindingElements.Hint.hidden = !font.embedded;
|
||||||
|
fontBindingElements.Status.textContent = "";
|
||||||
|
fontBindingElements.Submit.textContent = font.embedded ? "外置" : "嵌入";
|
||||||
|
fontBindingElements.Panel.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fontBindingError(message) {
|
||||||
|
if (/requires the original font/.test(message)) return "字形编号需匹配原字体";
|
||||||
|
if (/non-character glyph|private character|index-encoded/.test(message)) return "字形映射无法外置";
|
||||||
|
if (/unsupported content/.test(message)) return "扩展引用无法确认";
|
||||||
|
if (/does not permit/.test(message)) return "字体不允许嵌入";
|
||||||
|
if (/no glyph|glyph is unavailable/.test(message)) return "字体缺少所需字形";
|
||||||
|
if (/unavailable|not found|no name/.test(message)) return "匹配字体不可用";
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
function appendFontDetail(row, text) {
|
function appendFontDetail(row, text) {
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return;
|
return;
|
||||||
@@ -8847,6 +8997,7 @@ function setBusy(busy, text = "", percent = 0, status = "") {
|
|||||||
el.pageList.inert = busy;
|
el.pageList.inert = busy;
|
||||||
el.editorTools.inert = busy || Boolean(sealPreview);
|
el.editorTools.inert = busy || Boolean(sealPreview);
|
||||||
el.fontList.inert = busy;
|
el.fontList.inert = busy;
|
||||||
|
el.docFontList.inert = busy;
|
||||||
if (!busy) {
|
if (!busy) {
|
||||||
el.progressPanel.hidden = true;
|
el.progressPanel.hidden = true;
|
||||||
if (state.fontSyncPending) {
|
if (state.fontSyncPending) {
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ func RunWASM() {
|
|||||||
registerCallback("ofdgoImages", documentImages)
|
registerCallback("ofdgoImages", documentImages)
|
||||||
registerAsyncCallback("ofdgoExportImages", exportImages)
|
registerAsyncCallback("ofdgoExportImages", exportImages)
|
||||||
registerCallback("ofdgoMatchFontFiles", matchFontFiles)
|
registerCallback("ofdgoMatchFontFiles", matchFontFiles)
|
||||||
|
registerCallback("ofdgoChangeFontBinding", changeFontBinding)
|
||||||
registerCallback("ofdgoFontFaces", fontFaces)
|
registerCallback("ofdgoFontFaces", fontFaces)
|
||||||
registerCallback("ofdgoFontFace", fontFace)
|
registerCallback("ofdgoFontFace", fontFace)
|
||||||
registerCallback("ofdgoCreateDocument", createDocument)
|
registerCallback("ofdgoCreateDocument", createDocument)
|
||||||
@@ -467,6 +468,13 @@ func convertFile(args []js.Value) (any, error) {
|
|||||||
return awaitExport(args[4], progress.Stage, progress.Completed, progress.Total)
|
return awaitExport(args[4], progress.Stage, progress.Completed, progress.Total)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if value := settings.Get("fontMode"); value.Type() == js.TypeNumber {
|
||||||
|
mode := value.Int()
|
||||||
|
if mode < 0 || mode > int(ofdgo.FontExternal) {
|
||||||
|
return nil, fmt.Errorf("invalid font mode")
|
||||||
|
}
|
||||||
|
options.Fonts = ofdgo.FontMode(mode)
|
||||||
|
}
|
||||||
if value := settings.Get("compression"); !value.IsNull() && !value.IsUndefined() {
|
if value := settings.Get("compression"); !value.IsNull() && !value.IsUndefined() {
|
||||||
if err := compressionFromJS(value, &options.Compression); err != nil {
|
if err := compressionFromJS(value, &options.Compression); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -3472,6 +3480,33 @@ func changeAttachment(args []js.Value) (any, error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// changeFontBinding 调用库层字体资源操作,统一更新预览与撤销状态
|
||||||
|
// 入参: args 字体标识、保存方式及可选字体数据
|
||||||
|
// 返回: any 编辑状态, error 字体处理错误
|
||||||
|
func changeFontBinding(args []js.Value) (any, error) {
|
||||||
|
if len(args) < 2 {
|
||||||
|
return nil, fmt.Errorf("请选择字体")
|
||||||
|
}
|
||||||
|
return changeAtomicObjects(func() error {
|
||||||
|
id, mode := args[0].String(), args[1].Int()
|
||||||
|
if mode == int(ofdgo.FontExternal) {
|
||||||
|
return currentEditor.ExternalizeFont(context.Background(), id)
|
||||||
|
}
|
||||||
|
if mode != int(ofdgo.FontEmbed) {
|
||||||
|
return fmt.Errorf("字体方式无效")
|
||||||
|
}
|
||||||
|
var file ofdgo.FontFile
|
||||||
|
if len(args) > 2 && !args[2].IsNull() && !args[2].IsUndefined() {
|
||||||
|
data, err := bytesFromJS(args[2])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
file.Data = data
|
||||||
|
}
|
||||||
|
return currentEditor.EmbedFont(context.Background(), id, file, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// changePackageDocument 调用库层文档增删、改名或排序,不在浏览器内重写包结构
|
// changePackageDocument 调用库层文档增删、改名或排序,不在浏览器内重写包结构
|
||||||
// 入参: args 操作及标题、页面尺寸、文档索引或移动位置
|
// 入参: args 操作及标题、页面尺寸、文档索引或移动位置
|
||||||
// 返回: any 文档信息, error 错误信息
|
// 返回: any 文档信息, error 错误信息
|
||||||
|
|||||||
+34
-3
@@ -18,6 +18,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/xiaoqidun/pdfgo"
|
"github.com/xiaoqidun/pdfgo"
|
||||||
@@ -33,7 +34,7 @@ type OutputFormat struct {
|
|||||||
Raster bool `json:"raster,omitempty"`
|
Raster bool `json:"raster,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertProgress 回报open、pages、convert、prepare、write、write.*及export阶段,Total为0表示总量未知
|
// ConvertProgress 回报open、pages、convert、fonts、prepare、write、write.*及export阶段,Total为0表示总量未知
|
||||||
type ConvertProgress struct {
|
type ConvertProgress struct {
|
||||||
Stage string `json:"stage"`
|
Stage string `json:"stage"`
|
||||||
Completed int `json:"completed"`
|
Completed int `json:"completed"`
|
||||||
@@ -48,6 +49,7 @@ type ConvertProgress struct {
|
|||||||
// RendererOptions同时用于PDF局部合成和输出,在PDF.RendererOptions之后应用
|
// RendererOptions同时用于PDF局部合成和输出,在PDF.RendererOptions之后应用
|
||||||
// Backends统一配置转换与输出,优先于PDF和RendererOptions中的后端设置
|
// Backends统一配置转换与输出,优先于PDF和RendererOptions中的后端设置
|
||||||
// Compression统一配置输出及PDF图片导入,优先于PDF.Compression,文本输出不优化图片
|
// Compression统一配置输出及PDF图片导入,优先于PDF.Compression,文本输出不优化图片
|
||||||
|
// Fonts仅用于OFD输出,按字体处理全包引用,未处理原因通过报告返回
|
||||||
// PageOutput仅用于逐页格式,同步调用export写出一页并自行处理提交或回滚;此时output可为nil
|
// PageOutput仅用于逐页格式,同步调用export写出一页并自行处理提交或回滚;此时output可为nil
|
||||||
// 未提供PageOutput时,逐页格式打包ZIP;所有回调返回错误均会停止转换
|
// 未提供PageOutput时,逐页格式打包ZIP;所有回调返回错误均会停止转换
|
||||||
type ConvertOptions struct {
|
type ConvertOptions struct {
|
||||||
@@ -57,6 +59,7 @@ type ConvertOptions struct {
|
|||||||
PageRange string
|
PageRange string
|
||||||
SkipUnchanged bool
|
SkipUnchanged bool
|
||||||
Compression CompressionOptions
|
Compression CompressionOptions
|
||||||
|
Fonts FontMode
|
||||||
Backends *RenderBackends
|
Backends *RenderBackends
|
||||||
ReaderOptions []ReaderOption
|
ReaderOptions []ReaderOption
|
||||||
RendererOptions []RendererOption
|
RendererOptions []RendererOption
|
||||||
@@ -74,6 +77,7 @@ type ConvertReport struct {
|
|||||||
Bytes int64 `json:"bytes"`
|
Bytes int64 `json:"bytes"`
|
||||||
Unchanged bool `json:"unchanged,omitempty"`
|
Unchanged bool `json:"unchanged,omitempty"`
|
||||||
PDF PDFImportReport `json:"pdf"`
|
PDF PDFImportReport `json:"pdf"`
|
||||||
|
Fonts []FontChange `json:"fonts,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// convertReader 将取消上下文传递到随机读取
|
// convertReader 将取消上下文传递到随机读取
|
||||||
@@ -129,6 +133,9 @@ func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writ
|
|||||||
if report.Format.Value == "" {
|
if report.Format.Value == "" {
|
||||||
return report, fmt.Errorf("unsupported output format %s", options.Format)
|
return report, fmt.Errorf("unsupported output format %s", options.Format)
|
||||||
}
|
}
|
||||||
|
if options.Fonts > FontExternal || options.Fonts != FontKeep && format != "ofd" {
|
||||||
|
return report, fmt.Errorf("font processing requires OFD output and a valid mode")
|
||||||
|
}
|
||||||
if options.PageOutput != nil && !report.Format.Paged {
|
if options.PageOutput != nil && !report.Format.Paged {
|
||||||
return report, fmt.Errorf("page output requires a paged format")
|
return report, fmt.Errorf("page output requires a paged format")
|
||||||
}
|
}
|
||||||
@@ -175,7 +182,7 @@ func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writ
|
|||||||
if report.Input != "pdf" && report.Input != "ofd" {
|
if report.Input != "pdf" && report.Input != "ofd" {
|
||||||
return report, fmt.Errorf("unsupported input format")
|
return report, fmt.Errorf("unsupported input format")
|
||||||
}
|
}
|
||||||
if options.SkipUnchanged && options.Compression.Mode == CompressionUnchanged && report.Input == format && options.Pages == nil && strings.TrimSpace(options.PageRange) == "" {
|
if options.SkipUnchanged && options.Fonts == FontKeep && options.Compression.Mode == CompressionUnchanged && report.Input == format && options.Pages == nil && strings.TrimSpace(options.PageRange) == "" {
|
||||||
report.Unchanged = true
|
report.Unchanged = true
|
||||||
return report, nil
|
return report, nil
|
||||||
}
|
}
|
||||||
@@ -246,7 +253,7 @@ func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writ
|
|||||||
report.Pages = len(indices)
|
report.Pages = len(indices)
|
||||||
writer := convertWriter{context: ctx, writer: output, count: &report.Bytes}
|
writer := convertWriter{context: ctx, writer: output, count: &report.Bytes}
|
||||||
if format == "ofd" {
|
if format == "ofd" {
|
||||||
if options.Pages == nil && editor == nil && options.Compression.Mode == CompressionUnchanged {
|
if options.Pages == nil && editor == nil && options.Fonts == FontKeep && options.Compression.Mode == CompressionUnchanged {
|
||||||
if err = progress("write", 0, 0); err != nil {
|
if err = progress("write", 0, 0); err != nil {
|
||||||
return report, err
|
return report, err
|
||||||
}
|
}
|
||||||
@@ -261,6 +268,30 @@ func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
editor.OnWriteProgress = writeProgress
|
editor.OnWriteProgress = writeProgress
|
||||||
|
if options.Fonts != FontKeep {
|
||||||
|
if err = progress("fonts", 0, 0); err != nil {
|
||||||
|
return report, err
|
||||||
|
}
|
||||||
|
configuration := NewRenderer(&Reader{}, options.RendererOptions...)
|
||||||
|
if options.Backends != nil {
|
||||||
|
WithRenderBackends(*options.Backends)(configuration)
|
||||||
|
}
|
||||||
|
editor.SetRenderBackends(configuration.Backends())
|
||||||
|
editor.SetFontDirs(configuration.fontDirs...)
|
||||||
|
editor.SetFontFS(configuration.fontFS...)
|
||||||
|
report.Fonts, err = editor.ProcessFonts(ctx, options.Fonts)
|
||||||
|
if err != nil {
|
||||||
|
return report, err
|
||||||
|
}
|
||||||
|
if report.Input == "ofd" && options.Pages == nil && options.Compression.Mode == CompressionUnchanged && !slices.ContainsFunc(report.Fonts, func(change FontChange) bool { return change.Changed }) {
|
||||||
|
if err = progress("write", 0, 0); err != nil {
|
||||||
|
return report, err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(writer, io.NewSectionReader(source, 0, size))
|
||||||
|
report.Unchanged = err == nil
|
||||||
|
return report, err
|
||||||
|
}
|
||||||
|
}
|
||||||
if options.Pages == nil {
|
if options.Pages == nil {
|
||||||
_, err = editor.WriteToWithOptions(ctx, writer, WriteOptions{Compression: options.Compression})
|
_, err = editor.WriteToWithOptions(ctx, writer, WriteOptions{Compression: options.Compression})
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,551 @@
|
|||||||
|
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package ofdgo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FontMode 指定字体资源的保存方式,不改变文字位置或压缩选项
|
||||||
|
type FontMode uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
// FontKeep 保留原有嵌入方式
|
||||||
|
FontKeep FontMode = iota
|
||||||
|
// FontEmbed 嵌入可精确匹配的外部字体
|
||||||
|
FontEmbed
|
||||||
|
// FontExternal 移除可安全恢复字符映射的内嵌字体
|
||||||
|
FontExternal
|
||||||
|
)
|
||||||
|
|
||||||
|
// FontChange 返回单个字体的处理结果,Document为零基文档索引,Reason非空表示保持原样
|
||||||
|
type FontChange struct {
|
||||||
|
Document int `json:"document"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Changed bool `json:"changed"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmbedFont 嵌入当前文档指定字体,保留资源标识及全部使用位置,成功后可撤销
|
||||||
|
// file为空时从编辑器字体来源精确匹配;显式文件表示调用方确认替换,仍校验字符和字形
|
||||||
|
// 入参: ctx 取消上下文, id 字体标识, file 字体文件, index 集合索引
|
||||||
|
// 返回: error 字体、映射或包结构错误,失败时文档不变
|
||||||
|
func (e *Editor) EmbedFont(ctx context.Context, id string, file FontFile, index int) error {
|
||||||
|
return e.changeFont(ctx, id, FontEmbed, file, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExternalizeFont 将当前文档指定字体改为外部引用,不保证其他设备具备该字体
|
||||||
|
// 仅移除可由原字体字符映射等价恢复的显式字形变换,无法确认的映射保持内嵌并返回错误
|
||||||
|
// 入参: ctx 取消上下文, id 字体标识
|
||||||
|
// 返回: error 字体、映射或包结构错误,失败时文档不变
|
||||||
|
func (e *Editor) ExternalizeFont(ctx context.Context, id string) error {
|
||||||
|
return e.changeFont(ctx, id, FontExternal, FontFile{}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// changeFont 在独立包快照中修改单个字体,成功后一次提交
|
||||||
|
// 入参: ctx 取消上下文, id 字体标识, mode 保存方式, file 字体数据, index 集合索引
|
||||||
|
// 返回: error 检查或提交错误
|
||||||
|
func (e *Editor) changeFont(ctx context.Context, id string, mode FontMode, file FontFile, index int) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reader, err := e.Reader()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
scan, err := scanEditorFonts(ctx, reader, reader.DocumentIndex())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
changed, err := e.changeFontSnapshot(ctx, scan, id, mode, file, index)
|
||||||
|
if err != nil || !changed {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.commitFontSnapshot(ctx, reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessFonts 处理包内各文档的字体,逐字体保留不能转换的资源,取消或包结构错误时整体不提交
|
||||||
|
// 返回成功与未处理明细,整个操作计入一条撤销记录
|
||||||
|
// 入参: ctx 取消上下文, mode 保存方式
|
||||||
|
// 返回: []FontChange 字体处理结果, error 取消或结构错误
|
||||||
|
func (e *Editor) ProcessFonts(ctx context.Context, mode FontMode) ([]FontChange, error) {
|
||||||
|
if mode > FontExternal {
|
||||||
|
return nil, fmt.Errorf("invalid font mode")
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if mode == FontKeep {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
reader, err := e.Reader()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var report []FontChange
|
||||||
|
changed := false
|
||||||
|
for document := range reader.DocumentCount() {
|
||||||
|
scan, err := scanEditorFonts(ctx, reader, document)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids := slices.Sorted(maps.Keys(scan.fonts))
|
||||||
|
for _, id := range ids {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
declaration := scan.fonts[id]
|
||||||
|
if (mode == FontEmbed) == (declaration.location != "") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := FontChange{Document: document, ID: id, Name: declaration.font.FontName}
|
||||||
|
item.Changed, err = e.changeFontSnapshot(ctx, scan, id, mode, FontFile{}, 0)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
item.Reason = err.Error()
|
||||||
|
}
|
||||||
|
report = append(report, item)
|
||||||
|
if item.Changed {
|
||||||
|
changed = true
|
||||||
|
scan, err = scanEditorFonts(ctx, reader, document)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
err = e.commitFontSnapshot(ctx, reader)
|
||||||
|
}
|
||||||
|
return report, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// changeFontSnapshot 校验字体全部文字引用并修改独立阅读器,不改写正文定位
|
||||||
|
// 入参: ctx 取消上下文, scan 文档引用, id 字体标识, mode 保存方式, file 字体文件, index 集合索引
|
||||||
|
// 返回: bool 是否修改, error 字体或结构错误
|
||||||
|
func (e *Editor) changeFontSnapshot(ctx context.Context, scan *editorFontDocument, id string, mode FontMode, file FontFile, index int) (bool, error) {
|
||||||
|
declaration := scan.fonts[editorResourceID(id)]
|
||||||
|
if declaration == nil {
|
||||||
|
return false, fmt.Errorf("font not found: %s", id)
|
||||||
|
}
|
||||||
|
if (mode == FontEmbed) == (declaration.location != "") {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if scan.unsafe {
|
||||||
|
return false, fmt.Errorf("font references include unsupported content")
|
||||||
|
}
|
||||||
|
if e.backends.FontResources == nil {
|
||||||
|
return false, ErrBackendUnavailable
|
||||||
|
}
|
||||||
|
var data []byte
|
||||||
|
var err error
|
||||||
|
explicit := mode == FontEmbed && len(file.Data) != 0
|
||||||
|
if mode == FontExternal {
|
||||||
|
data, err = scan.reader.readFile(declaration.location)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
file = FontFile{Data: data}
|
||||||
|
if bytes.HasPrefix(data, []byte("ttcf")) {
|
||||||
|
index = fontCollectionIndex(data, []string{declaration.font.FontName, declaration.font.FamilyName}, declaration.font.Bold, declaration.font.Italic)
|
||||||
|
}
|
||||||
|
} else if len(file.Data) == 0 {
|
||||||
|
file, err = e.resolveBindingFont(scan, declaration)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
index = 0
|
||||||
|
}
|
||||||
|
parsed, err := e.backends.FontResources.OpenFontResource(file, index)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if mode == FontEmbed {
|
||||||
|
if err := validateFontEmbedding(parsed.Data); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changes := make(map[string][]byte)
|
||||||
|
verifiedGlyphs := false
|
||||||
|
for _, name := range slices.Sorted(maps.Keys(scan.files)) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
file := scan.files[name]
|
||||||
|
var patches []editorXMLPatch
|
||||||
|
for _, node := range file.texts {
|
||||||
|
if editorResourceID(node.attr("Font")) != editorResourceID(id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var object TextObject
|
||||||
|
if err := xml.Unmarshal(file.data[node.start:node.end], &object); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if err := checkFontBinding(object, parsed, mode); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if explicit && len(object.CGTransform) != 0 && !verifiedGlyphs {
|
||||||
|
source, err := e.resolveBindingFont(scan, declaration)
|
||||||
|
if err != nil || !sameBindingFont(source.Data, parsed.Data) {
|
||||||
|
return false, fmt.Errorf("font glyph mapping requires the original font")
|
||||||
|
}
|
||||||
|
verifiedGlyphs = true
|
||||||
|
}
|
||||||
|
if mode == FontExternal {
|
||||||
|
for _, child := range node.children {
|
||||||
|
if child.matchesOFD("CGTransform") {
|
||||||
|
patches = append(patches, editorXMLPatch{start: child.start, end: child.end})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(patches) != 0 {
|
||||||
|
changes[name] = editorPatchXML(file.data, patches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data = declaration.file.data
|
||||||
|
node := declaration.node
|
||||||
|
if modified, ok := changes[declaration.file.name]; ok {
|
||||||
|
data = modified
|
||||||
|
node, err = fontBindingNode(data, id)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fontFile := node.child("FontFile")
|
||||||
|
if mode == FontEmbed {
|
||||||
|
digest := sha256.Sum256(parsed.Data)
|
||||||
|
name := fmt.Sprintf("Fonts/%x%s", digest, parsed.Extension)
|
||||||
|
if existing, err := scan.reader.readFile(name); err != nil || !bytes.Equal(existing, parsed.Data) {
|
||||||
|
name = packageAvailableName(scan.reader, changes, name)
|
||||||
|
changes[name] = bytes.Clone(parsed.Data)
|
||||||
|
}
|
||||||
|
content := node.textXML("FontFile", "/"+name)
|
||||||
|
if fontFile != nil {
|
||||||
|
data = editorPatchXML(data, []editorXMLPatch{{fontFile.start, fontFile.end, content}})
|
||||||
|
} else if node.open == node.end {
|
||||||
|
data = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, node, content)})
|
||||||
|
} else {
|
||||||
|
data = editorPatchXML(data, []editorXMLPatch{{node.close, node.close, content}})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if fontFile == nil {
|
||||||
|
return false, fmt.Errorf("font file declaration is missing")
|
||||||
|
}
|
||||||
|
fragment := editorPatchXML(data[node.start:node.end], []editorXMLPatch{{fontFile.start - node.start, fontFile.end - node.start, nil}})
|
||||||
|
for _, field := range []string{"FontName", "FamilyName"} {
|
||||||
|
root, err := parseEditorXML(fragment)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
value := root.attr(field)
|
||||||
|
if stripped := externalFontName(value); stripped != value {
|
||||||
|
fragment, err = editorXMLAttribute(fragment, root, field, stripped)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data = editorPatchXML(data, []editorXMLPatch{{node.start, node.end, fragment}})
|
||||||
|
}
|
||||||
|
if explicit {
|
||||||
|
for _, field := range []struct{ name, value string }{{"FontName", parsed.Font.FontName}, {"FamilyName", parsed.Font.FamilyName}, {"Charset", "unicode"}} {
|
||||||
|
node, err := fontBindingNode(data, id)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
fragment, err := editorXMLAttribute(data, node, field.name, field.value)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
data = editorPatchXML(data, []editorXMLPatch{{node.start, node.end, fragment}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changes[declaration.file.name] = data
|
||||||
|
working := *scan.reader
|
||||||
|
working.files = maps.Clone(scan.reader.files)
|
||||||
|
next := *scan
|
||||||
|
next.reader = &working
|
||||||
|
if err := next.apply(ctx, changes); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if err := working.initRoot(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if mode == FontExternal {
|
||||||
|
if err := pruneExternalFontFiles(ctx, &working, declaration.location); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*scan.reader = working
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fontBindingNode 在修改后的资源XML中重新定位字体声明
|
||||||
|
// 入参: data 资源XML, id 字体标识
|
||||||
|
// 返回: *editorXML 字体节点, error 解析或查找错误
|
||||||
|
func fontBindingNode(data []byte, id string) (*editorXML, error) {
|
||||||
|
root, err := parseEditorXML(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if fonts := root.child("Fonts"); fonts != nil {
|
||||||
|
for _, node := range fonts.children {
|
||||||
|
if node.matchesOFD("Font") && editorResourceID(node.attr("ID")) == editorResourceID(id) {
|
||||||
|
return node, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("font declaration is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameBindingFont 比较独立字体的全部数据表,忽略重新封装产生的校验和差异
|
||||||
|
// 入参: source 原字体, target 待嵌入字体
|
||||||
|
// 返回: bool 是否保留原字体内容及字形编号
|
||||||
|
func sameBindingFont(source, target []byte) bool {
|
||||||
|
a, err := fontFileTables(source, 0)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
b, err := fontFileTables(target, 0)
|
||||||
|
if err != nil || len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for tag, data := range a {
|
||||||
|
other := b[tag]
|
||||||
|
if tag == "head" && len(data) >= 12 && len(other) >= 12 {
|
||||||
|
if !bytes.Equal(data[:8], other[:8]) || !bytes.Equal(data[12:], other[12:]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else if !bytes.Equal(data, other) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveBindingFont 精确匹配当前字体并提取独立字体数据,集合索引不传递到提取结果
|
||||||
|
// 入参: scan 文档引用, declaration 字体声明
|
||||||
|
// 返回: FontFile 独立字体, error 匹配或解析错误
|
||||||
|
func (e *Editor) resolveBindingFont(scan *editorFontDocument, declaration *editorFontDeclaration) (FontFile, error) {
|
||||||
|
reader := &Reader{Zip: scan.reader.Zip, files: maps.Clone(scan.reader.files), documentIndex: scan.index}
|
||||||
|
if err := reader.initRoot(); err != nil {
|
||||||
|
return FontFile{}, err
|
||||||
|
}
|
||||||
|
if _, err := reader.Doc(); err != nil {
|
||||||
|
return FontFile{}, err
|
||||||
|
}
|
||||||
|
definition := declaration.font
|
||||||
|
definition.FontFile = ""
|
||||||
|
reader.fontCache[definition.ID] = &definition
|
||||||
|
resolved, err := e.newRenderer(reader).ResolveFont(definition.ID, true)
|
||||||
|
if err != nil {
|
||||||
|
return FontFile{}, err
|
||||||
|
}
|
||||||
|
if !resolved.Exact {
|
||||||
|
return FontFile{}, fmt.Errorf("exact font is unavailable")
|
||||||
|
}
|
||||||
|
file := FontFile{Data: resolved.Data}
|
||||||
|
index := 0
|
||||||
|
if bytes.HasPrefix(file.Data, []byte("ttcf")) && resolved.Face != nil {
|
||||||
|
index = resolved.Face.Index
|
||||||
|
}
|
||||||
|
data, err := file.Face(index)
|
||||||
|
return FontFile{Data: data}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkFontBinding 核对字符覆盖及字形编号,外置仅允许等价的一对一字符映射
|
||||||
|
// 入参: object 文字对象, font 目标字体, mode 保存方式
|
||||||
|
// 返回: error 字符或字形无法保留的原因
|
||||||
|
func checkFontBinding(object TextObject, font *FontResource, mode FontMode) error {
|
||||||
|
var chars []rune
|
||||||
|
for _, code := range object.TextCode {
|
||||||
|
if code.Index != "" {
|
||||||
|
return fmt.Errorf("font uses index-encoded text")
|
||||||
|
}
|
||||||
|
chars = append(chars, textCodeRunes(code.Value)...)
|
||||||
|
}
|
||||||
|
covered := make(map[int]bool)
|
||||||
|
for _, transform := range object.CGTransform {
|
||||||
|
values := strings.Fields(transform.Glyphs)
|
||||||
|
if transform.CodePosition < 0 || transform.CodeCount < 1 || transform.GlyphCount < 1 || transform.CodePosition > len(chars)-transform.CodeCount || transform.GlyphCount != len(values) {
|
||||||
|
return fmt.Errorf("invalid font glyph mapping")
|
||||||
|
}
|
||||||
|
if mode == FontExternal && transform.CodeCount != transform.GlyphCount {
|
||||||
|
return fmt.Errorf("font uses non-character glyph mapping")
|
||||||
|
}
|
||||||
|
for i, value := range values {
|
||||||
|
glyph, err := strconv.ParseUint(value, 10, 16)
|
||||||
|
if err != nil || glyph == 0 || glyph >= uint64(font.NumGlyphs()) {
|
||||||
|
return fmt.Errorf("font glyph is unavailable")
|
||||||
|
}
|
||||||
|
if mode == FontExternal && uint16(glyph) != font.GlyphIndex(chars[transform.CodePosition+i]) {
|
||||||
|
return fmt.Errorf("font uses non-character glyph mapping")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := transform.CodePosition; i < transform.CodePosition+transform.CodeCount; i++ {
|
||||||
|
if covered[i] {
|
||||||
|
return fmt.Errorf("overlapping font glyph mapping")
|
||||||
|
}
|
||||||
|
covered[i] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, char := range chars {
|
||||||
|
if mode == FontExternal && (char >= 0xe000 && char <= 0xf8ff || char >= 0xf0000 && char <= 0x10fffd) {
|
||||||
|
return fmt.Errorf("font uses private character mapping")
|
||||||
|
}
|
||||||
|
if !covered[i] && font.GlyphIndex(char) == 0 {
|
||||||
|
return fmt.Errorf("font has no glyph for U+%04X", char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// externalFontName 去除PDF字体子集前缀,不猜测或改写其他名称
|
||||||
|
// 入参: name 字体名称
|
||||||
|
// 返回: string 外部匹配名称
|
||||||
|
func externalFontName(name string) string {
|
||||||
|
if len(name) > 7 && name[6] == '+' {
|
||||||
|
for _, char := range name[:6] {
|
||||||
|
if char < 'A' || char > 'Z' {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name[7:]
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// commitFontSnapshot 重新建立资源缓存并记录包快照,避免旧字体缓存跨撤销或重做复用
|
||||||
|
// 入参: ctx 取消上下文, reader 修改后的独立快照
|
||||||
|
// 返回: error 重建错误
|
||||||
|
func (e *Editor) commitFontSnapshot(ctx context.Context, reader *Reader) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
clean := &Reader{Zip: reader.Zip, files: maps.Clone(reader.files), documentIndex: reader.DocumentIndex(), encryption: e.encryption}
|
||||||
|
if err := clean.initRoot(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
next, err := clean.Editor()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
before := e.transactionSnapshot()
|
||||||
|
e.restoreFontSnapshot(*next)
|
||||||
|
if change := e.recordChange(); change != nil {
|
||||||
|
after := e.transactionSnapshot()
|
||||||
|
change.undo = func(e *Editor) { e.restoreFontSnapshot(before) }
|
||||||
|
change.redo = func(e *Editor) { e.restoreFontSnapshot(after) }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreFontSnapshot 恢复字体修改前后的包状态,保留运行配置与撤销队列
|
||||||
|
// 入参: state 包快照
|
||||||
|
func (e *Editor) restoreFontSnapshot(state Editor) {
|
||||||
|
state = state.transactionSnapshot()
|
||||||
|
state.history, state.historyIndex, state.historyLimit = e.history, e.historyIndex, e.historyLimit
|
||||||
|
state.serial, state.revision = e.serial, e.revision
|
||||||
|
state.backends, state.fontDirs, state.fontFS = e.backends, e.fontDirs, e.fontFS
|
||||||
|
state.encryption, state.output, state.OnWriteProgress = e.encryption, e.output, e.OnWriteProgress
|
||||||
|
state.fontRenderer, state.fontSourcesCache = nil, nil
|
||||||
|
state.fontMetrics = make(map[string]FontMetrics)
|
||||||
|
*e = state
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneExternalFontFiles 保留全包仍引用的字体,未确认的扩展引用使清理保持保守
|
||||||
|
// 入参: ctx 取消上下文, reader 包快照, candidate 原字体路径
|
||||||
|
// 返回: error 取消或读取错误
|
||||||
|
func pruneExternalFontFiles(ctx context.Context, reader *Reader, candidate string) error {
|
||||||
|
used := make(map[string]bool)
|
||||||
|
for i := range reader.DocumentCount() {
|
||||||
|
files, err := reader.documentFiles(ctx, i)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
maps.Copy(used, files)
|
||||||
|
}
|
||||||
|
removed := make(map[string]bool)
|
||||||
|
key := strings.ToLower(cleanPackagePath(candidate))
|
||||||
|
if used[key] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, name := range reader.fileNamesFold {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(strings.ToLower(name), ".xml") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := reader.readFile(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
root, err := parseEditorXML(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
base := ""
|
||||||
|
if root.matchesOFD("Res") {
|
||||||
|
base = root.attr("BaseLoc")
|
||||||
|
}
|
||||||
|
var retains func(*editorXML) bool
|
||||||
|
retains = func(node *editorXML) bool {
|
||||||
|
if classifyOFDNamespace(node.name.Space) == ofdXMLUnknown || slices.Contains([]string{"CustomTags", "Extensions", "ExtendData", "Data"}, node.name.Local) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, attr := range node.attrs {
|
||||||
|
if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != "http://www.w3.org/XML/1998/namespace" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if node.matchesOFD("FontFile") && strings.EqualFold(resolveResourcePath(name, base, strings.TrimSpace(editorImportText(data, node))), candidate) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return slices.ContainsFunc(node.children, retains)
|
||||||
|
}
|
||||||
|
if retains(root) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !used[key] {
|
||||||
|
removed[key] = true
|
||||||
|
}
|
||||||
|
for name := range reader.files {
|
||||||
|
if removed[strings.ToLower(name)] {
|
||||||
|
delete(reader.files, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reader.Zip != nil {
|
||||||
|
reader.Zip = &zip.Reader{Comment: reader.Zip.Comment, File: slices.DeleteFunc(slices.Clone(reader.Zip.File), func(file *zip.File) bool { return removed[strings.ToLower(cleanPackagePath(file.Name))] })}
|
||||||
|
}
|
||||||
|
return reader.initRoot()
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
// 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"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// editorFontDocument 保存一个文档的字体声明与完整内容引用,不进入附件和签名封装
|
||||||
|
type editorFontDocument struct {
|
||||||
|
reader *Reader
|
||||||
|
index int
|
||||||
|
files map[string]*editorFontXML
|
||||||
|
fonts map[string]*editorFontDeclaration
|
||||||
|
unsafe bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// editorFontXML 保存内容XML及其直接文件引用
|
||||||
|
type editorFontXML struct {
|
||||||
|
name string
|
||||||
|
data []byte
|
||||||
|
root *editorXML
|
||||||
|
links []editorFontLink
|
||||||
|
texts []*editorXML
|
||||||
|
}
|
||||||
|
|
||||||
|
// editorFontLink 记录需要随共享资源隔离而更新的路径
|
||||||
|
type editorFontLink struct {
|
||||||
|
node *editorXML
|
||||||
|
attribute string
|
||||||
|
target string
|
||||||
|
}
|
||||||
|
|
||||||
|
// editorFontDeclaration 关联字体声明、所属资源文件及嵌入数据路径
|
||||||
|
type editorFontDeclaration struct {
|
||||||
|
font Font
|
||||||
|
file *editorFontXML
|
||||||
|
node *editorXML
|
||||||
|
location string
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanEditorFonts 按标准内容路径扫描正文、模板、注解、复合图元、图案及文字裁剪
|
||||||
|
// 入参: ctx 取消上下文, reader 包快照, index 文档索引
|
||||||
|
// 返回: *editorFontDocument 字体引用, error 读取或结构错误
|
||||||
|
func scanEditorFonts(ctx context.Context, reader *Reader, index int) (*editorFontDocument, error) {
|
||||||
|
scan := &editorFontDocument{reader: reader, index: index, files: make(map[string]*editorFontXML), fonts: make(map[string]*editorFontDeclaration)}
|
||||||
|
queue := []string{cleanPackagePath(reader.OFD.DocBody[index].DocRoot)}
|
||||||
|
for len(queue) != 0 {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
name := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
if actual := reader.fileNamesFold[strings.ToLower(name)]; actual != "" {
|
||||||
|
name = actual
|
||||||
|
}
|
||||||
|
if scan.files[name] != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := reader.readFile(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
root, err := parseEditorXML(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
file := &editorFontXML{name: name, data: data, root: root}
|
||||||
|
scan.files[name] = file
|
||||||
|
base := ""
|
||||||
|
if root.matchesOFD("Res") {
|
||||||
|
base = root.attr("BaseLoc")
|
||||||
|
}
|
||||||
|
var visit func(*editorXML) error
|
||||||
|
visit = func(node *editorXML) error {
|
||||||
|
if classifyOFDNamespace(node.name.Space) == ofdXMLUnknown {
|
||||||
|
scan.unsafe = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if node.matchesOFD("Attachments") || node.matchesOFD("Signatures") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if slices.Contains([]string{"CustomTags", "Extensions", "ExtendData", "Data"}, node.name.Local) {
|
||||||
|
scan.unsafe = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, attr := range node.attrs {
|
||||||
|
if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != "http://www.w3.org/XML/1998/namespace" {
|
||||||
|
scan.unsafe = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if node.matchesOFD("Font") && node.parent != nil && node.parent.matchesOFD("Fonts") && root.matchesOFD("Res") {
|
||||||
|
if node.childAt("FontFile", 1) != nil {
|
||||||
|
return fmt.Errorf("multiple font files for %s", node.attr("ID"))
|
||||||
|
}
|
||||||
|
var font Font
|
||||||
|
if err := xml.Unmarshal(data[node.start:node.end], &font); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id := editorResourceID(font.ID)
|
||||||
|
if id == "" || scan.fonts[id] != nil {
|
||||||
|
return fmt.Errorf("missing or duplicate font ID %q", font.ID)
|
||||||
|
}
|
||||||
|
location := ""
|
||||||
|
if font.FontFile != "" {
|
||||||
|
location = resolveResourcePath(name, base, font.FontFile)
|
||||||
|
}
|
||||||
|
scan.fonts[id] = &editorFontDeclaration{font: font, file: file, node: node, location: location}
|
||||||
|
}
|
||||||
|
if node.matchesOFD("TextObject") || node.matchesOFD("Text") && node.parent != nil && node.parent.matchesOFD("Area") {
|
||||||
|
file.texts = append(file.texts, node)
|
||||||
|
if node.attr("Font") == "" {
|
||||||
|
scan.unsafe = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attribute, value := "", ""
|
||||||
|
switch node.name.Local {
|
||||||
|
case "PublicRes", "DocumentRes", "PageRes", "Annotations":
|
||||||
|
value = strings.TrimSpace(editorImportText(data, node))
|
||||||
|
case "Page", "TemplatePage", "CompositeGraphicUnit":
|
||||||
|
attribute, value = "BaseLoc", node.attr("BaseLoc")
|
||||||
|
case "PageAnnot":
|
||||||
|
attribute, value = "FileLoc", node.attr("FileLoc")
|
||||||
|
case "DrawParam":
|
||||||
|
attribute, value = "Link", node.attr("Link")
|
||||||
|
case "FileLoc":
|
||||||
|
if node.parent != nil && node.parent.matchesOFD("Page") && root.matchesOFD("Annotations") {
|
||||||
|
value = strings.TrimSpace(editorImportText(data, node))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value != "" {
|
||||||
|
target := resolveResourcePath(name, base, value)
|
||||||
|
if actual := reader.fileNamesFold[strings.ToLower(target)]; actual != "" {
|
||||||
|
target = actual
|
||||||
|
}
|
||||||
|
file.links = append(file.links, editorFontLink{node: node, attribute: attribute, target: target})
|
||||||
|
queue = append(queue, target)
|
||||||
|
}
|
||||||
|
for _, child := range node.children {
|
||||||
|
if err := visit(child); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := visit(root); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply 隔离其他文档共享的内容XML,再提交字体修改及上层路径引用
|
||||||
|
// 入参: ctx 取消上下文, changes 按原文件路径保存的修改
|
||||||
|
// 返回: error 引用扫描或XML修改错误
|
||||||
|
func (s *editorFontDocument) apply(ctx context.Context, changes map[string][]byte) error {
|
||||||
|
shared := make(map[string]bool)
|
||||||
|
if s.reader.DocumentCount() > 1 {
|
||||||
|
for index := range s.reader.DocumentCount() {
|
||||||
|
if index == s.index {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files, err := s.reader.documentFiles(ctx, index)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
maps.Copy(shared, files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paths := make(map[string]string)
|
||||||
|
for name := range changes {
|
||||||
|
if shared[strings.ToLower(name)] {
|
||||||
|
paths[name] = packageAvailableName(s.reader, changes, name+".font.xml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for changed := true; changed; {
|
||||||
|
changed = false
|
||||||
|
for name, file := range s.files {
|
||||||
|
if paths[name] != "" || !shared[strings.ToLower(name)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, link := range file.links {
|
||||||
|
if paths[link.target] != "" {
|
||||||
|
paths[name] = packageAvailableName(s.reader, changes, name+".font.xml")
|
||||||
|
changed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range slices.Sorted(maps.Keys(s.files)) {
|
||||||
|
file := s.files[name]
|
||||||
|
if !slices.ContainsFunc(file.links, func(link editorFontLink) bool { return paths[link.target] != "" }) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, modified := changes[name]
|
||||||
|
if !modified {
|
||||||
|
data = file.data
|
||||||
|
}
|
||||||
|
root, err := parseEditorXML(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var patches []editorXMLPatch
|
||||||
|
var patch func(*editorXML) error
|
||||||
|
patch = func(node *editorXML) error {
|
||||||
|
for _, link := range file.links {
|
||||||
|
target := paths[link.target]
|
||||||
|
if target == "" || node.name != link.node.name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if link.attribute != "" && node.attr(link.attribute) == link.node.attr(link.attribute) {
|
||||||
|
fragment, err := editorXMLAttribute(data, node, link.attribute, "/"+target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
header, err := parseEditorXML(fragment)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
patches = append(patches, editorXMLPatch{node.start, node.open, fragment[:header.open]})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if link.attribute == "" && editorImportText(data, node) == editorImportText(file.data, link.node) {
|
||||||
|
patches = append(patches, editorXMLContent(data, node, editorFontXMLText("/"+target)))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, child := range node.children {
|
||||||
|
if err := patch(child); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := patch(root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(patches) != 0 {
|
||||||
|
data, modified = editorPatchXML(data, patches), true
|
||||||
|
}
|
||||||
|
if modified {
|
||||||
|
changes[name] = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootName := cleanPackagePath(s.reader.OFD.DocBody[s.index].DocRoot)
|
||||||
|
if actual := s.reader.fileNamesFold[strings.ToLower(rootName)]; actual != "" {
|
||||||
|
rootName = actual
|
||||||
|
}
|
||||||
|
if target := paths[rootName]; target != "" {
|
||||||
|
data, err := s.reader.readFile("OFD.xml")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
root, err := parseEditorXML(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
node := root.childAt("DocBody", s.index).child("DocRoot")
|
||||||
|
changes[s.reader.fileNamesFold["ofd.xml"]] = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, node, editorFontXMLText("/"+target))})
|
||||||
|
}
|
||||||
|
for name, data := range changes {
|
||||||
|
if target := paths[name]; target != "" {
|
||||||
|
name = target
|
||||||
|
}
|
||||||
|
s.reader.files[name] = data
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// editorFontXMLText 编码资源路径文本,不改变包内名称
|
||||||
|
// 入参: value 原始文本
|
||||||
|
// 返回: []byte XML文本
|
||||||
|
func editorFontXMLText(value string) []byte {
|
||||||
|
var data bytes.Buffer
|
||||||
|
_ = xml.EscapeText(&data, []byte(value))
|
||||||
|
return data.Bytes()
|
||||||
|
}
|
||||||
@@ -91,7 +91,32 @@ func (f sfntResourceMetrics) Write() []byte { return fontSFNTData(f.SFNT) }
|
|||||||
// 入参: sfnt 已解析字体
|
// 入参: sfnt 已解析字体
|
||||||
// 返回: bool 是否支持裁剪
|
// 返回: bool 是否支持裁剪
|
||||||
func sfntSubsettable(sfnt *font.SFNT) bool {
|
func sfntSubsettable(sfnt *font.SFNT) bool {
|
||||||
return sfnt.IsTrueType && !slices.ContainsFunc([]string{"fvar", "COLR", "CBDT", "sbix", "SVG "}, func(tag string) bool { return sfnt.Tables[tag] != nil })
|
return sfnt.IsTrueType && fontEmbeddingFlags(sfnt)&0x0100 == 0 && !slices.ContainsFunc([]string{"fvar", "COLR", "CBDT", "sbix", "SVG "}, func(tag string) bool { return sfnt.Tables[tag] != nil })
|
||||||
|
}
|
||||||
|
|
||||||
|
// fontEmbeddingFlags 读取OpenType的嵌入标志,未提供OS/2表时返回零
|
||||||
|
// 入参: sfnt 已解析字体
|
||||||
|
// 返回: uint16 嵌入标志
|
||||||
|
func fontEmbeddingFlags(sfnt *font.SFNT) uint16 {
|
||||||
|
if table := sfnt.Tables["OS/2"]; len(table) >= 10 {
|
||||||
|
return binary.BigEndian.Uint16(table[8:10])
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateFontEmbedding 校验字体是否允许作为可编辑轮廓字体嵌入
|
||||||
|
// 入参: data 独立OpenType字体
|
||||||
|
// 返回: error 字体解析或嵌入限制
|
||||||
|
func validateFontEmbedding(data []byte) error {
|
||||||
|
sfnt, err := font.ParseSFNT(data, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
flags := fontEmbeddingFlags(sfnt)
|
||||||
|
if flags&0x0200 != 0 || flags&0x000e != 0 && flags&0x0008 == 0 {
|
||||||
|
return fmt.Errorf("font does not permit editable embedding")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sfntFontName 获取字体中的名称
|
// sfntFontName 获取字体中的名称
|
||||||
@@ -116,6 +141,9 @@ func subsetSFNTFont(data []byte, glyphs []uint16, mapped bool) ([]byte, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if fontEmbeddingFlags(sfnt)&0x0100 != 0 {
|
||||||
|
return bytes.Clone(data), nil
|
||||||
|
}
|
||||||
if mapped {
|
if mapped {
|
||||||
usage := newEditorFontUsage()
|
usage := newEditorFontUsage()
|
||||||
for _, id := range glyphs {
|
for _, id := range glyphs {
|
||||||
@@ -159,7 +187,7 @@ func subsetSourceSFNTFont(data []byte, usage *editorFontUsage) []byte {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
sfnt, err := font.ParseSFNT(data, 0)
|
sfnt, err := font.ParseSFNT(data, 0)
|
||||||
if err != nil || !sfnt.IsTrueType {
|
if err != nil || !sfnt.IsTrueType || fontEmbeddingFlags(sfnt)&0x0100 != 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
for tag := range sfnt.Tables {
|
for tag := range sfnt.Tables {
|
||||||
|
|||||||
Reference in new issue
Block a user