mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-30 01:27:26 +08:00
Compare commits
22
Commits
c2b0339fdf
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
385d7aa531 | ||
|
|
94157b10c3 | ||
|
|
aebcda51a3 | ||
|
|
19084bb40b | ||
|
|
545b697310 | ||
|
|
1a25472b43 | ||
|
|
af80fcc9ba | ||
|
|
838a769c5b | ||
|
|
5b4f2472f7 | ||
|
|
fb51fd3b07 | ||
|
|
80e548f93e | ||
|
|
9c3a04b7eb | ||
|
|
9bb6c9141a | ||
|
|
4320b53d4a | ||
|
|
e5d7f577c7 | ||
|
|
7a23dcc6dd | ||
|
|
12aaada982 | ||
|
|
62631472ea | ||
|
|
9d6bf3e87f | ||
|
|
f8c8259502 | ||
|
|
f4429b126a | ||
|
|
50898934dc |
No files matched your search
+1
-1
@@ -1,5 +1,5 @@
|
||||
# 基础镜像
|
||||
FROM alpine:3.24.2
|
||||
FROM alpine:3.24.3
|
||||
|
||||
# 作者信息
|
||||
LABEL authors="肖其顿 (XIAO QI DUN)"
|
||||
|
||||
+426
-117
@@ -110,9 +110,12 @@ function createDocumentState() {
|
||||
styleOriginal: null,
|
||||
objectClipboardToken: null,
|
||||
styleClipboard: null,
|
||||
objectDistribution: null,
|
||||
outlineExpanded: new Map(),
|
||||
textFonts: [],
|
||||
textFontID: null,
|
||||
textFontInitialized: false,
|
||||
textFontInitializing: null,
|
||||
textDefaults: { type: "TextObject", size: 16 * 25.4 / 72, color: "#000000", wrap: true, align: "left", paragraphHeight: 0, letterSpacing: 0 },
|
||||
ofdBytes: null,
|
||||
sourcePDF: false,
|
||||
@@ -681,9 +684,12 @@ function createPrintDialog(tab) {
|
||||
});
|
||||
}
|
||||
|
||||
function openPrintPanel() {
|
||||
const tab = documentTab();
|
||||
async function openPrintPanel() {
|
||||
const tab = documentTab(), content = documentContentKey(state);
|
||||
if (!tab.state.doc || !tab.state.ready || document.body.hasAttribute("data-busy") || formDialogOpen()) return;
|
||||
const current = () => isForegroundDocument(tab) && content === documentContentKey(state);
|
||||
if (!await canvasEditor.commitNudge() || !current() || !await canvasEditor.commitText() || !current()
|
||||
|| !await canvasEditor.commitCrop() || !current() || document.body.hasAttribute("data-busy") || formDialogOpen()) return;
|
||||
tab.print ||= createPrintDialog(tab);
|
||||
tab.print.open({tab, document:documentEntryKey(tab.state.doc.documents?.[tab.state.doc.documentIndex || 0]), call:(name, ...args) => callTabWASM(tab, name, ...args), page:tab.state.pageIndex,
|
||||
selection:tab.state.pageSelection.size ? selectedPageIndexes() : [], annotations:tab.state.renderAnnotations,
|
||||
@@ -697,15 +703,17 @@ const fontManager = new FontManager({
|
||||
onChange: scheduleFontSync,
|
||||
onPermissionChange: updateFontPermissionHint,
|
||||
});
|
||||
const fontPicker = new FontPicker(el.textFont, el.textFontToggle, el.textFontList, changeTextFont, loadEditorFonts);
|
||||
const fontPicker = new FontPicker(el.textFont, el.textFontToggle, el.textFontList, changeTextFont, loadEditorFonts, groupTextFonts);
|
||||
const textFormatButtons = [...document.querySelectorAll("[data-text-format]")];
|
||||
const textBackground = document.querySelector("#textBackground");
|
||||
const editorInputs = new Map();
|
||||
const annotationFields = Object.fromEntries(["CreateForm", "CreateCancel", "Content", "Old", "Font", "Text", "TextRow", "Image", "ImageRow", "Author", "LinkKind", "Address", "AddressRow", "Target", "TargetRow", "X", "Y", "Width", "Height", "Angle", "Opacity", "Tile", "Placement", "PlacementLabel", "Pick", "Scope", "Pages"].map(key => [key, document.querySelector(`#annotation${key}`)]));
|
||||
el.editorTools.addEventListener("scroll", () => fontPicker.position());
|
||||
|
||||
const canvasEditor = new CanvasEditor(el.viewerPanel, {
|
||||
textControls: [el.textAlign, el.textFont, el.textFontToggle, el.textFontAdd, el.textFontList, el.textSize, el.textColor, el.textLineHeight, el.textSpacing, el.textWrap, el.paragraphButton, el.paragraphPanel, textBackground, ...textFormatButtons, el.undoButton, el.redoButton, el.saveButton, el.saveAsButton, el.fontInput, document.querySelector("#textReflow")],
|
||||
keepTextFocus: () => fontPicker.open || el.closePanel.open || el.paragraphPanel.open || canvasEditor.input?.fontDialog,
|
||||
prepareTextCommit: flushEditorInputs,
|
||||
onTextFormat: changeRangeFormat,
|
||||
onRichInput: ensureRichInput,
|
||||
textClipboardScope: () => `${documentTab().id}:${documentContentKey(state)}:${state.editorInfo?.revision ?? ""}`,
|
||||
@@ -864,13 +872,14 @@ editorClick(el.editObjectButton, () => {
|
||||
editCanvasObject(canvasEditor.selected);
|
||||
}
|
||||
});
|
||||
el.textSize.addEventListener("change", () => changeTextStyle(false));
|
||||
el.textColor.addEventListener("change", () => changeTextStyle(true));
|
||||
bindEditorInput(el.textSize, () => changeTextStyle(false));
|
||||
bindEditorInput(el.textColor, () => changeTextStyle(true));
|
||||
for (const button of textFormatButtons) button.addEventListener("click", () => changeRangeFormat(button.dataset.textFormat));
|
||||
textBackground.addEventListener("change", () => changeRangeFormat("background", textBackground.value));
|
||||
bindEditorInput(textBackground, value => changeRangeFormat("background", value));
|
||||
document.addEventListener("pointerdown", flushEditorInputs, true);
|
||||
editorClick(el.textWrap, () => changeParagraph(!currentTextStyle().wrap), true);
|
||||
el.textAlign.addEventListener("change", () => changeTextAlignment());
|
||||
el.textLineHeight.addEventListener("change", () => changeParagraph(undefined, "lineHeight"));
|
||||
bindEditorInput(el.textLineHeight, () => changeParagraph(undefined, "lineHeight"));
|
||||
editorClick(el.paragraphButton, () => {
|
||||
const item = currentTextStyle();
|
||||
for (const key of ["leftIndent", "rightIndent", "firstLineIndent"]) {
|
||||
@@ -1205,7 +1214,7 @@ el.paragraphForm.addEventListener("submit", async event => {
|
||||
if (isForegroundDocument(tab) && el.paragraphPanel.open) el.paragraphStatus.textContent = error.message;
|
||||
}
|
||||
});
|
||||
el.textSpacing.addEventListener("change", () => changeParagraph(undefined, "letterSpacing"));
|
||||
bindEditorInput(el.textSpacing, () => changeParagraph(undefined, "letterSpacing"));
|
||||
el.shapeWidth.addEventListener("focus", () => { el.shapeWidth.defaultValue = el.shapeWidth.value; });
|
||||
for (const input of [el.textSize, el.shapeWidth, el.textLineHeight, el.textSpacing]) {
|
||||
input.addEventListener("keydown", (event) => {
|
||||
@@ -1215,6 +1224,9 @@ for (const input of [el.textSize, el.shapeWidth, el.textLineHeight, el.textSpaci
|
||||
event.stopPropagation();
|
||||
if (event.repeat) return;
|
||||
if (event.key === "Escape") {
|
||||
const control = editorInputs.get(input);
|
||||
control.pending = null;
|
||||
control.handled = true;
|
||||
updateObjectControls(canvasEditor.selected, true);
|
||||
if (input === el.shapeWidth && canvasEditor.selected?.type !== "PathObject") {
|
||||
input.value = input.defaultValue;
|
||||
@@ -1252,7 +1264,7 @@ el.objectAlign.addEventListener("change", () => {
|
||||
el.objectDistribute.addEventListener("change", () => {
|
||||
const axis = el.objectDistribute.value;
|
||||
el.objectDistribute.value = "";
|
||||
if (axis && canvasEditor.items().length >= 3) {
|
||||
if (axis && !el.objectDistribute.disabled && state.objectDistribution?.axes?.includes(axis)) {
|
||||
return changeDocument("ofdgoDistributeObjects", canvasEditor.selected, axis);
|
||||
}
|
||||
});
|
||||
@@ -1299,11 +1311,15 @@ for (const [button, tool] of [[el.drawLineButton, "line"], [el.drawRectangleButt
|
||||
}
|
||||
editorClick(el.eraseButton, () => toggleDrawingTool(el.eraseMode.value));
|
||||
el.eraseMode.addEventListener("change", () => {
|
||||
if (canvasEditor.tool.startsWith("erase-")) canvasEditor.setTool(el.eraseMode.value);
|
||||
if (canvasEditor.tool.startsWith("erase-")) {
|
||||
canvasEditor.setTool(el.eraseMode.value);
|
||||
canvasEditor.focus();
|
||||
}
|
||||
});
|
||||
for (const input of [el.shapeFill, el.shapeFillColor, el.shapeStroke, el.shapeStrokeColor, el.shapeWidth]) {
|
||||
for (const input of [el.shapeFill, el.shapeStroke]) {
|
||||
input.addEventListener("change", changeShapeStyle);
|
||||
}
|
||||
for (const input of [el.shapeFillColor, el.shapeStrokeColor, el.shapeWidth]) bindEditorInput(input, () => changeShapeStyle({ target: input }));
|
||||
el.addPageButton.addEventListener("change", async () => {
|
||||
const action = el.addPageButton.value;
|
||||
el.addPageButton.value = "";
|
||||
@@ -1398,9 +1414,11 @@ for (const [button, ungroup] of [[el.groupObjectsButton, false], [el.ungroupObje
|
||||
});
|
||||
}
|
||||
el.arrowTool.addEventListener("change", async () => {
|
||||
const tab = documentTab(), content = documentContentKey(state);
|
||||
const current = () => isForegroundDocument(tab) && state.editing && content === documentContentKey(state);
|
||||
const tool = el.arrowTool.value;
|
||||
el.arrowTool.value = "";
|
||||
if (await canvasEditor.commitText() && await canvasEditor.commitCrop()) {
|
||||
if (await canvasEditor.commitText() && current() && await canvasEditor.commitCrop() && current()) {
|
||||
const item = canvasEditor.selected;
|
||||
if (lineShape(item?.shape)) {
|
||||
const box = item.geometry;
|
||||
@@ -1411,6 +1429,7 @@ el.arrowTool.addEventListener("change", async () => {
|
||||
state.selectObjects = true;
|
||||
canvasEditor.setTool(tool);
|
||||
updateControls();
|
||||
canvasEditor.focus();
|
||||
}
|
||||
});
|
||||
el.copyStyleButton.addEventListener("click", async () => {
|
||||
@@ -1798,6 +1817,7 @@ el.annotationButton.addEventListener("click", toggleAnnotations);
|
||||
document.addEventListener("copy", copySelection);
|
||||
document.addEventListener("cut", copySelection);
|
||||
el.viewerPanel.addEventListener("paste", pasteEditorContent);
|
||||
el.editorTools.addEventListener("paste", pasteEditorContent);
|
||||
document.addEventListener("selectionchange", syncSelection);
|
||||
el.exportFormat.addEventListener("change", () => updateDPIControl());
|
||||
el.outputCompression.addEventListener("change", () => {
|
||||
@@ -1924,6 +1944,11 @@ el.documentNewTab.addEventListener("click", openEmptyDocumentTab);
|
||||
updateSidebarState();
|
||||
boot();
|
||||
|
||||
function editorShortcutTarget(target) {
|
||||
const field = target.closest?.("input, textarea, select, [contenteditable]");
|
||||
return !field || el.editorTools.contains(field) && field.matches('select, input[type="checkbox"], input[type="radio"], input[type="color"], input[type="range"]');
|
||||
}
|
||||
|
||||
function handleKeyDown(event) {
|
||||
if (sealPreview) {
|
||||
if (event.key === "Escape") { event.preventDefault(); finishSealPosition(); }
|
||||
@@ -1956,10 +1981,11 @@ function handleKeyDown(event) {
|
||||
return;
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && key.toLowerCase() === "a" && state.doc
|
||||
&& el.viewerPanel.contains(target) && !target.closest("input, textarea, select, [contenteditable]")) {
|
||||
&& (el.viewerPanel.contains(target) || state.editing && el.editorTools.contains(target)) && editorShortcutTarget(target)) {
|
||||
event.preventDefault();
|
||||
if (!document.body.hasAttribute("data-busy") && !formDialogOpen()) {
|
||||
selectDocumentText();
|
||||
if (state.editing) canvasEditor.selectAll(state.pageIndex);
|
||||
else selectDocumentText();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1985,7 +2011,7 @@ function handleKeyDown(event) {
|
||||
}
|
||||
}
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (state.editing && !target.closest("input, textarea, select, [contenteditable]")
|
||||
if (state.editing && editorShortcutTarget(target)
|
||||
&& (key.toLowerCase() === "z" || (!event.shiftKey && key.toLowerCase() === "y"))) {
|
||||
event.preventDefault();
|
||||
(key.toLowerCase() === "y" || event.shiftKey ? el.redoButton : el.undoButton).click();
|
||||
@@ -2001,6 +2027,11 @@ function handleKeyDown(event) {
|
||||
if (!state.doc) {
|
||||
return;
|
||||
}
|
||||
if (state.editing && !event.shiftKey && el.editorTools.contains(target) && editorShortcutTarget(target)
|
||||
&& (key === "Delete" || key === "F2")) {
|
||||
canvasEditor.keyDown(event, true);
|
||||
return;
|
||||
}
|
||||
if (key === "Escape" && !event.shiftKey && state.documentSelection && el.viewerPanel.contains(target)
|
||||
&& !target.closest("input, textarea, select, [contenteditable]")) {
|
||||
event.preventDefault();
|
||||
@@ -2010,11 +2041,7 @@ function handleKeyDown(event) {
|
||||
}
|
||||
if (key === "F3") {
|
||||
event.preventDefault();
|
||||
if (state.searchMatches.length) {
|
||||
selectSearchMatch(event.shiftKey ? Math.max(0, state.searchIndex) - 1 : state.searchIndex + 1);
|
||||
} else {
|
||||
focusSearch();
|
||||
}
|
||||
focusSearch(event.shiftKey ? -1 : 1);
|
||||
return;
|
||||
}
|
||||
if (key === "Escape" && !event.shiftKey && !el.searchPanel.hidden && (el.pageListPanel.contains(target) || el.viewerPanel.contains(target))) {
|
||||
@@ -2834,6 +2861,10 @@ function detachDocumentView() {
|
||||
settings.sequence++;
|
||||
fontBinding.sequence++;
|
||||
fontPicker.close();
|
||||
fontPicker.changing = null;
|
||||
for (const [input, control] of editorInputs) {
|
||||
editorInputs.set(input, { ...control, pending: null, task: null, handled: true });
|
||||
}
|
||||
canvasEditor.cancelNudge();
|
||||
canvasEditor.clear();
|
||||
canvasEditor.setTool("");
|
||||
@@ -3003,6 +3034,9 @@ async function releaseDocumentTab(tab) {
|
||||
if (!prepared && workspace.objectClipboard === clipboard) workspace.objectClipboard = null;
|
||||
}
|
||||
tab.closed = true;
|
||||
for (const control of editorInputs.values()) {
|
||||
if (control.pending?.tab === tab) { control.pending = null; control.handled = true; }
|
||||
}
|
||||
tab.print?.destroy();
|
||||
tab.print = null;
|
||||
tab.opening?.controller.abort();
|
||||
@@ -4136,15 +4170,61 @@ function currentTextStyle() {
|
||||
const text = selectedText(canvasEditor.selected) || state.textDefaults;
|
||||
if (canvasEditor.input?.rich) {
|
||||
const rich = canvasEditor.input.rich, layout = richTextLayout(rich.model, ...rich.selection());
|
||||
return { ...text, ...rich.state(), ...layout, paragraphHeight: layout.lineHeight };
|
||||
return { ...text, ...rich.state(), ...textFaceStyle(rich.selectedRuns().map(run => run.format)), ...layout, paragraphHeight: layout.lineHeight };
|
||||
}
|
||||
if (text.richText) {
|
||||
const formats = text.richText.runs.map(run => run.format);
|
||||
const formats = text.richText.runs.map(run => effectiveTextFormat(run.format));
|
||||
const layout = richTextLayout(text.richText);
|
||||
return { ...text, ...layout, paragraphHeight: layout.lineHeight, ...Object.fromEntries(["font", "size", "color", "bold", "italic", "underline", "strikeout", "shadow", "background"]
|
||||
.map(key => [key, formats.every(format => format[key] === formats[0]?.[key]) ? formats[0]?.[key] : undefined])) };
|
||||
}
|
||||
return canvasEditor.input?.style ? { ...text, ...canvasEditor.input.style } : text;
|
||||
if (text.items) return { ...text, ...textFaceStyle(text.items.flatMap(item => textFontRuns(item).map(run => run.format))) };
|
||||
return text === state.textDefaults ? text : effectiveTextFormat(canvasEditor.input?.style ? { ...text, ...canvasEditor.input.style } : text);
|
||||
}
|
||||
|
||||
function documentFontChoice(id) {
|
||||
const font = state.doc?.fonts?.find(font => font.id === id);
|
||||
return { ...(font?.status !== "fallback" && font?.status !== "missing" ? font?.matchedFace : null), id: `embedded:${id}`, name: font?.fontName || id, embedded: true };
|
||||
}
|
||||
|
||||
function textFontChoice(id) {
|
||||
const rich = canvasEditor.input?.rich;
|
||||
const choice = rich?.choices.get(id);
|
||||
return choice ? { ...choice, richKey: id } : id ? documentFontChoice(id) : state.textDefaults.fontChoice;
|
||||
}
|
||||
|
||||
function effectiveTextFormat(format) {
|
||||
const font = textFontChoice(format.font), face = canvasEditor.input?.rich?.faces.get(format.font);
|
||||
return { ...format, bold: Boolean(format.bold || font?.bold || Number(face?.weight) >= 600), italic: Boolean(format.italic || font?.italic || face?.style === "italic") };
|
||||
}
|
||||
|
||||
function textFaceStyle(formats) {
|
||||
formats = formats.map(effectiveTextFormat);
|
||||
return Object.fromEntries(["bold", "italic"].map(key => [key, formats.every(format => format[key] === formats[0]?.[key]) ? formats[0]?.[key] : undefined]));
|
||||
}
|
||||
|
||||
function textFontRuns(item) {
|
||||
return (item?.items || [item || state.textDefaults]).flatMap(item => item.richText?.runs || [{ text: item.text || "", format: { font: item.font || "", bold: Boolean(item.bold), italic: Boolean(item.italic) } }]);
|
||||
}
|
||||
|
||||
function textFontSource(font) {
|
||||
return font.embedded ? "embedded" : font.file || ["stored", "upload"].includes(font.source) ? "upload" : "system";
|
||||
}
|
||||
|
||||
function textFontIdentity(font) {
|
||||
return `${textFontSource(font)}:${font.embedded ? font.richKey || font.id?.slice(9) : font.id ?? font.postscriptName}`;
|
||||
}
|
||||
|
||||
async function groupTextFonts(fonts, tab = documentTab()) {
|
||||
if (!fonts.some(font => font.familyKey)) return fonts.map((_, index) => [index]);
|
||||
const { groups } = await callTabWASM(tab, "ofdgoFontMenu", JSON.stringify({ faces: fonts.map(font => ({ familyKey: font.familyKey, bold: font.bold, italic: font.italic })), sources: fonts.map(textFontSource) }));
|
||||
return groups;
|
||||
}
|
||||
|
||||
function sharedTextFont(formats) {
|
||||
const choices = formats.map(format => textFontChoice(format.font));
|
||||
const first = choices[0];
|
||||
return first && choices.every(font => font && (font.familyKey && font.familyKey === first.familyKey && textFontSource(font) === textFontSource(first) || textFontIdentity(font) === textFontIdentity(first))) ? formats[0].font : undefined;
|
||||
}
|
||||
|
||||
async function loadRichText(item, firstFace, tab = documentTab(), firstData = null, reflow = false) {
|
||||
@@ -4206,8 +4286,51 @@ async function reflowCanvasText() {
|
||||
updateObjectControls(canvasEditor.selected, true);
|
||||
}
|
||||
|
||||
function bindEditorInput(input, change) {
|
||||
editorInputs.set(input, { input, change, pending: null, task: null, handled: false });
|
||||
const remember = () => {
|
||||
const control = editorInputs.get(input);
|
||||
control.pending = { value: input.value, tab: documentTab(), content: documentContentKey(state), editing: canvasEditor.input, selected: canvasEditor.selected };
|
||||
control.handled = false;
|
||||
};
|
||||
input.addEventListener("input", remember);
|
||||
input.addEventListener("change", () => {
|
||||
const control = editorInputs.get(input);
|
||||
let task;
|
||||
if (!control.handled) {
|
||||
if (!control.pending) remember();
|
||||
task = flushEditorInput(control);
|
||||
}
|
||||
control.handled = false;
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
function flushEditorInput(control) {
|
||||
if (control.task || !control.pending) return control.task;
|
||||
control.task = (async () => {
|
||||
while (control.pending) {
|
||||
const pending = control.pending;
|
||||
control.pending = null;
|
||||
control.handled = true;
|
||||
if (!isForegroundDocument(pending.tab) || pending.content !== documentContentKey(state)
|
||||
|| pending.editing !== canvasEditor.input || pending.selected !== canvasEditor.selected) continue;
|
||||
control.input.value = pending.value;
|
||||
await control.change(pending.value);
|
||||
}
|
||||
})().finally(() => { control.task = null; });
|
||||
return control.task;
|
||||
}
|
||||
|
||||
function flushEditorInputs(event) {
|
||||
const tasks = [...editorInputs.values()].filter(control => !event || !control.input.contains(event.target))
|
||||
.map(flushEditorInput).concat(fontPicker.prepareCommit(event), canvasEditor.input?.fontTask).filter(Boolean);
|
||||
return tasks.length ? Promise.all(tasks) : null;
|
||||
}
|
||||
|
||||
async function changeRangeFormat(key, value) {
|
||||
if (!state.ready || document.body.hasAttribute("data-busy") || canvasEditor.input?.composing) return;
|
||||
if (key === "bold" || key === "italic") return changeTextFace(null, key, value ?? currentTextStyle()[key] !== true);
|
||||
const tab = documentTab(), editing = canvasEditor.input;
|
||||
try {
|
||||
if (!canvasEditor.selected && !canvasEditor.input) {
|
||||
@@ -4248,15 +4371,15 @@ async function validateRichText(item, model, width, fonts = []) {
|
||||
attempted.add(id);
|
||||
const original = (await callWASM("ofdgoEditorFont", id)).bytes;
|
||||
const [source] = await callWASM("ofdgoFontFaces", original);
|
||||
if (!source?.postscriptName) throw error;
|
||||
if (!source?.postscriptName || !source.familyKey) throw error;
|
||||
await fontManager.loadFaces(data => callWASM("ofdgoFontFaces", data));
|
||||
const candidates = fontManager.editorFonts().filter(font => !font.disabled && font.postscriptName === source.postscriptName);
|
||||
const candidates = fontManager.editorFonts().filter(font => !font.disabled && font.postscriptName === source.postscriptName && font.familyKey === source.familyKey);
|
||||
let recovered;
|
||||
for (const choice of candidates) {
|
||||
try {
|
||||
const data = await readTextFont(choice, tab);
|
||||
const [face] = await callWASM("ofdgoFontFaces", data);
|
||||
if (face?.postscriptName !== source.postscriptName) continue;
|
||||
if (face?.postscriptName !== source.postscriptName || face.familyKey !== source.familyKey) continue;
|
||||
await callWASM("ofdgoCheckTextFont", data, model.runs.filter(run => run.format.font === id).map(run => run.text).join(""));
|
||||
recovered = { key: id, data, choice };
|
||||
break;
|
||||
@@ -4271,8 +4394,9 @@ async function validateRichText(item, model, width, fonts = []) {
|
||||
rich.imports.set(id, recovered.data);
|
||||
rich.choices.set(id, recovered.choice);
|
||||
const previous = rich.faces.get(id);
|
||||
if (previous) document.fonts.delete(previous);
|
||||
rich.faces.set(id, face); document.fonts.add(face);
|
||||
rich.faces.set(id, face);
|
||||
if (previous && ![...rich.faces.values()].includes(previous)) document.fonts.delete(previous);
|
||||
document.fonts.add(face);
|
||||
if (!editing.composing && !editing.saving) { rich.selection(); rich.render(); updateObjectControls(canvasEditor.selected); }
|
||||
}
|
||||
}
|
||||
@@ -4338,9 +4462,10 @@ async function beginCanvasText(index, box, surface, page) {
|
||||
const item = { ...state.textDefaults, ...box, wrap: Boolean(box.wrap),
|
||||
index, page, surface, node, text: "", draft: true, fontData: data };
|
||||
const model = { ...richTextModel(item), autoWidth: !box.wrap };
|
||||
const key = `temp-font-${crypto.randomUUID()}`;
|
||||
const key = font.embedded ? font.id.slice(9) : `temp-font-${crypto.randomUUID()}`;
|
||||
const embedded = font.embedded && state.doc?.fonts?.some(font => font.id === key && font.embedded);
|
||||
model.runs[0].format.font = key;
|
||||
canvasEditor.editText(item, face, null, { model, faces: new Map([[key, face]]), fonts: [{ key, data }] });
|
||||
canvasEditor.editText(item, face, null, { model, faces: new Map([[key, face]]), fonts: embedded ? [] : [{ key, data }], resources: new Map([[key, data]]) });
|
||||
} catch (err) {
|
||||
if (isForegroundDocument(tab) && openSeq === state.openSeq) showError(err, false);
|
||||
} finally {
|
||||
@@ -4351,48 +4476,76 @@ async function beginCanvasText(index, box, surface, page) {
|
||||
|
||||
function editorFonts(item) {
|
||||
const fonts = fontManager.editorFonts();
|
||||
if (item) {
|
||||
fonts.unshift({ id: `embedded:${item.font}`, name: item.fontName || item.font, embedded: true, disabled: !canEditObject(item, "textContent") });
|
||||
const embedded = new Map();
|
||||
for (const member of item?.items || (item ? [item] : [])) {
|
||||
for (const id of member.richText ? member.richText.runs.map(run => run.format.font) : [member.font]) {
|
||||
if (!id || embedded.has(id)) continue;
|
||||
const font = state.doc?.fonts?.find(font => font.id === id);
|
||||
embedded.set(id, { ...documentFontChoice(id), name: font?.fontName || (id === member.font ? member.fontName : "") || id,
|
||||
embedded: true, disabled: !canEditObject(member, "textContent") });
|
||||
}
|
||||
return fonts;
|
||||
}
|
||||
return [...embedded.values(), ...fonts];
|
||||
}
|
||||
|
||||
async function loadEditorFonts() {
|
||||
const tab = documentTab(), target = tab.state;
|
||||
const tasks = [];
|
||||
if (!state.fontCatalogLoading && fontManager.canReadLocal() && !fontManager.catalogLoaded && fontManager.permission !== "denied") {
|
||||
target.fontCatalogLoading = true;
|
||||
tasks.push((async () => {
|
||||
async function loadEditorFonts(requestPermission = true, tab = documentTab(), waitPending = false) {
|
||||
const target = tab.state;
|
||||
const tasks = waitPending ? [target.fontCatalogLoading, target.fontFacesLoading].filter(Boolean) : [];
|
||||
if (!target.fontCatalogLoading && fontManager.canReadLocal() && !fontManager.catalogLoaded && fontManager.permission !== "denied"
|
||||
&& (requestPermission || fontManager.permission === "granted")) {
|
||||
target.fontCatalogLoading = (async () => {
|
||||
try {
|
||||
await fontManager.queryLocal();
|
||||
if (isForegroundDocument(tab)) refreshEditorFonts();
|
||||
refreshEditorFonts();
|
||||
await loadEditorFonts(false, tab);
|
||||
} catch (err) {
|
||||
if (isForegroundDocument(tab)) setStatus(err?.name === "NotAllowedError" ? "系统字体未授权" : String(err.message || err));
|
||||
if (requestPermission && isForegroundDocument(tab)) setStatus(err?.name === "NotAllowedError" ? "系统字体未授权" : String(err.message || err));
|
||||
} finally {
|
||||
target.fontCatalogLoading = false;
|
||||
}
|
||||
})());
|
||||
})();
|
||||
tasks.push(target.fontCatalogLoading);
|
||||
}
|
||||
if (!state.fontFacesLoading && fontManager.userFonts.some(font => font.enabled && !font.faces)) {
|
||||
target.fontFacesLoading = true;
|
||||
tasks.push((async () => {
|
||||
if (!target.fontFacesLoading && (fontManager.userFonts.some(font => font.enabled && !font.faces) || fontManager.catalog.some(font => font.blob && !font.menuFamily && !font.metadataFailed))) {
|
||||
const wasmSeq = target.wasmSeq;
|
||||
const current = () => !tab.closed && !target.wasmExited && target.wasmSeq === wasmSeq;
|
||||
target.fontFacesLoading = (async () => {
|
||||
try {
|
||||
await fontManager.loadFaces(data => callTabWASM(tab, "ofdgoFontFaces", data));
|
||||
if (isForegroundDocument(tab)) refreshEditorFonts();
|
||||
await fontManager.loadFaces(data => callTabWASM(tab, "ofdgoFontFaces", data), current);
|
||||
refreshEditorFonts();
|
||||
} finally {
|
||||
target.fontFacesLoading = false;
|
||||
if (!current() && state.ready) loadEditorFonts(false);
|
||||
}
|
||||
})());
|
||||
})();
|
||||
tasks.push(target.fontFacesLoading);
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
|
||||
async function initializeTextFont(tab) {
|
||||
const target = tab.state;
|
||||
if (target.textFontInitialized) return;
|
||||
if (target.textFontInitializing) return target.textFontInitializing;
|
||||
const wasmSeq = target.wasmSeq;
|
||||
const current = () => !tab.closed && !target.wasmExited && target.wasmSeq === wasmSeq;
|
||||
target.textFontInitializing = (async () => {
|
||||
await loadEditorFonts(false, tab, true);
|
||||
if (!current()) return;
|
||||
const font = await fontManager.defaultFont((faces, families) => callTabWASM(tab, "ofdgoFontPreferenceOrder", JSON.stringify({
|
||||
faces: faces.map(({ family, fullName, postscriptName, style, names }) => ({ family, fullName, postscriptName, style, names })), families
|
||||
})), async (font, text) => callTabWASM(tab, "ofdgoCheckTextFont", await readTextFont(font, tab), text), current);
|
||||
if (!current()) return;
|
||||
target.textDefaults.fontChoice = font || (fontManager.editorFonts().length ? null : undefined);
|
||||
target.textFontInitialized = true;
|
||||
if (isForegroundDocument(tab)) refreshEditorFonts();
|
||||
})();
|
||||
try { await target.textFontInitializing; }
|
||||
finally { target.textFontInitializing = null; }
|
||||
}
|
||||
|
||||
function refreshEditorFonts() {
|
||||
const open = fontPicker.open;
|
||||
const selected = state.textFonts[Number(fontPicker.value)];
|
||||
const query = el.textFont.value === (selected?.fullName || selected?.name || "") ? "" : el.textFont.value;
|
||||
updateTextFonts(canvasEditor.selected, true);
|
||||
if (open && !el.textFont.disabled) { if (query) el.textFont.value = query; fontPicker.show(query, false); }
|
||||
updateTextFonts(canvasEditor.selected, true, true);
|
||||
}
|
||||
|
||||
async function readTextFont(font, tab = documentTab()) {
|
||||
@@ -4407,96 +4560,217 @@ async function readTextFont(font, tab = documentTab()) {
|
||||
return result.bytes;
|
||||
}
|
||||
|
||||
function updateTextFonts(item, refresh = false) {
|
||||
function updateTextFonts(item, refresh = false, preserve = false) {
|
||||
item = selectedText(item);
|
||||
const rich = canvasEditor.input?.rich;
|
||||
if (rich) {
|
||||
const format = rich.state(), id = format.font;
|
||||
const id = sharedTextFont(rich.selectedRuns().map(run => run.format));
|
||||
const key = `rich:${id ?? "mixed"}:${rich.faces.size}`;
|
||||
if (refresh || state.textFontID !== key) {
|
||||
if (document.activeElement === el.textFont && fontPicker.open) return;
|
||||
if (!preserve && document.activeElement === el.textFont && fontPicker.open) return;
|
||||
state.textFontID = key;
|
||||
state.textFonts = editorFonts(null);
|
||||
for (const [font, face] of rich.faces) {
|
||||
for (const font of rich.faces.keys()) {
|
||||
const choice = rich.choices?.get(font);
|
||||
state.textFonts.unshift(choice || { id: `embedded:${font}`, name: font === rich.editing.item.font ? rich.editing.item.fontName || font : face.family, embedded: !rich.imports.has(font), richKey: font });
|
||||
if (choice) state.textFonts = state.textFonts.filter(value => textFontIdentity(value) !== textFontIdentity(choice));
|
||||
state.textFonts.unshift({ ...(choice || { ...documentFontChoice(font),
|
||||
name: state.doc?.fonts?.find(value => value.id === font)?.fontName || (font === rich.editing.item.font ? rich.editing.item.fontName : "") || font,
|
||||
embedded: !rich.imports.has(font) }), richKey: font });
|
||||
}
|
||||
fontPicker.setFonts(state.textFonts, id === undefined ? null : state.textFonts.find(font => font.richKey === id || rich.choices?.get(id) === font));
|
||||
fontPicker.setFonts(state.textFonts, id === undefined ? null : state.textFonts.find(font => font.richKey === id), preserve);
|
||||
el.textFont.placeholder = id === undefined ? "混合" : "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (item?.richText) item = { ...item, font: currentTextStyle().font };
|
||||
const id = item?.type === "TextObject" && !item.draft ? item.font : null;
|
||||
const key = item?.items ? `selection:${item.items.map(member => member.id).join(",")}:${id || ""}` : id;
|
||||
const id = item?.type === "TextObject" && !item.draft ? sharedTextFont(textFontRuns(item).map(run => run.format)) : null;
|
||||
const key = item?.items ? `selection:${item.items.map(member => member.id).join(",")}:${id || ""}`
|
||||
: item?.richText ? `rich:${item.id}:${item.richText.runs.map(run => run.format.font).join(",")}` : id;
|
||||
if (refresh || state.textFontID !== key) {
|
||||
state.textFontID = key;
|
||||
state.textFonts = editorFonts(id ? item : null);
|
||||
state.textFonts = editorFonts(item?.type === "TextObject" && !item.draft ? item : null);
|
||||
const selected = canvasEditor.input?.fontChoice || (id ? state.textFonts[0] : state.textDefaults.fontChoice);
|
||||
if (selected && (canvasEditor.input || selected.embedded)
|
||||
&& !state.textFonts.some(font => (font.id || font.postscriptName) === (selected.id || selected.postscriptName))) {
|
||||
state.textFonts.unshift(selected);
|
||||
}
|
||||
const mixed = (item?.items || item?.richText) && !id;
|
||||
fontPicker.setFonts(state.textFonts, mixed ? null : selected || state.textFonts[0]);
|
||||
fontPicker.setFonts(state.textFonts, mixed ? null : selected || (state.textDefaults.fontChoice === null ? null : state.textFonts[0]), preserve);
|
||||
el.textFont.placeholder = mixed ? "混合" : "";
|
||||
if (!id && !item?.items && !item?.richText) state.textDefaults.fontChoice = state.textFonts[Number(fontPicker.value)];
|
||||
if (!id && !item?.items && !item?.richText) state.textDefaults.fontChoice = state.textFonts[Number(fontPicker.value)] || (state.textDefaults.fontChoice === null ? null : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeTextFont() {
|
||||
function textFontStyleKey(format) {
|
||||
return JSON.stringify([format.font || "", Boolean(format.bold), Boolean(format.italic)]);
|
||||
}
|
||||
|
||||
async function prepareTextFaces(tab, runs, font, key, value, rich) {
|
||||
const { openSeq, wasmSeq, doc } = tab.state;
|
||||
const current = () => !tab.closed && openSeq === tab.state.openSeq && wasmSeq === tab.state.wasmSeq && doc === tab.state.doc && (!rich || canvasEditor.input === rich.editing);
|
||||
const callWASM = (name, ...args) => {
|
||||
if (!current()) throw new Error("文档已更改");
|
||||
return callTabWASM(tab, name, ...args);
|
||||
};
|
||||
const choices = new Map(), data = new Map();
|
||||
const originals = new Map(runs.map(run => [run.format.font || "", textFontChoice(run.format.font)]));
|
||||
const add = font => { if (font && !font.disabled && !choices.has(textFontIdentity(font))) choices.set(textFontIdentity(font), { ...font }); };
|
||||
if (font) add(font);
|
||||
if (rich) for (const id of rich.faces.keys()) add({ ...textFontChoice(id), richKey: id });
|
||||
for (const original of originals.values()) add(original);
|
||||
for (const font of tab.state.doc?.fonts || []) if (font.embedded || font.status === "matched") add(documentFontChoice(font.id));
|
||||
for (const font of fontManager.editorFonts()) add(font);
|
||||
const read = async choice => {
|
||||
const id = textFontIdentity(choice);
|
||||
if (!data.has(id)) data.set(id, choice.richKey && (rich?.imports.get(choice.richKey) || rich?.resources.get(choice.richKey)) || await readTextFont(choice, tab));
|
||||
if (!current()) throw new Error("文档已更改");
|
||||
return data.get(id);
|
||||
};
|
||||
const candidates = [...choices.values()];
|
||||
for (const choice of candidates) {
|
||||
if (choice.menuFamily || ![...originals.values()].some(font => textFontIdentity(font || {}) === textFontIdentity(choice)) && (!font || textFontIdentity(choice) !== textFontIdentity(font))) continue;
|
||||
try {
|
||||
const [metadata] = await callWASM("ofdgoFontFaces", await read(choice));
|
||||
if (metadata) Object.assign(choice, metadata, { index: choice.index });
|
||||
} catch (error) {
|
||||
if (!current() || !font || textFontIdentity(choice) === textFontIdentity(font)) throw error;
|
||||
}
|
||||
}
|
||||
const groups = await groupTextFonts(candidates, tab), changes = new Map(), prepared = new Map();
|
||||
for (const run of runs) {
|
||||
const from = { font: run.format.font || "", bold: Boolean(run.format.bold), italic: Boolean(run.format.italic) };
|
||||
const original = choices.get(textFontIdentity(originals.get(from.font) || {}));
|
||||
const target = font ? choices.get(textFontIdentity(font)) : original;
|
||||
if (!target) throw new Error("字体不可用");
|
||||
const bold = key === "bold" ? value : Boolean(from.bold || original?.bold);
|
||||
const italic = key === "italic" ? value : Boolean(from.italic || original?.italic);
|
||||
const group = groups.find(group => group.includes(candidates.indexOf(target))).map(index => candidates[index]);
|
||||
let { index } = await callWASM("ofdgoFontMenu", JSON.stringify({ faces: group.map(({ bold, italic }) => ({ bold, italic })), bold, italic }));
|
||||
if (index < 0 && font) index = group.indexOf(target);
|
||||
if (index < 0) throw new Error("此字体没有对应款式");
|
||||
const selected = group[index], identity = textFontIdentity(selected);
|
||||
if (!prepared.has(identity)) prepared.set(identity, { choice: selected, key: selected.richKey || (selected.embedded ? selected.id.slice(9) : `temp-font-${crypto.randomUUID()}`), data: await read(selected), text: "" });
|
||||
const entry = prepared.get(identity);
|
||||
entry.text += run.text;
|
||||
changes.set(textFontStyleKey(from), { from, to: { font: entry.key,
|
||||
bold: !key && selected.bold && bold ? from.bold : Boolean(bold || selected.bold),
|
||||
italic: !key && selected.italic && italic ? from.italic : Boolean(italic || selected.italic) } });
|
||||
}
|
||||
for (const entry of prepared.values()) {
|
||||
try { await callWASM("ofdgoCheckTextFont", entry.data, entry.text); }
|
||||
catch (error) {
|
||||
if (!error.missingGlyphs || !entry.choice.embedded || !entry.choice.familyKey) throw error;
|
||||
let recovered = false;
|
||||
for (const choice of candidates.filter(choice => !choice.embedded && choice.familyKey === entry.choice.familyKey && choice.postscriptName === entry.choice.postscriptName)) {
|
||||
try {
|
||||
const bytes = await read(choice);
|
||||
await callWASM("ofdgoCheckTextFont", bytes, entry.text);
|
||||
entry.data = bytes;
|
||||
entry.choice = { ...choice, richKey: entry.key };
|
||||
recovered = true;
|
||||
break;
|
||||
} catch { if (!current()) throw error; }
|
||||
}
|
||||
if (!recovered) throw error;
|
||||
}
|
||||
if (!current()) throw new Error("文档已更改");
|
||||
if (rich) entry.face = await new FontFace(`ofdgo-edit-font-${crypto.randomUUID()}`, entry.data, fontFaceStyle(entry.data)).load();
|
||||
}
|
||||
return { changes: [...changes.values()], prepared: [...prepared.values()], fonts: [...prepared.values()].filter(entry => !entry.choice.embedded).map(({ key, data, text }) => ({ key, data, text })) };
|
||||
}
|
||||
|
||||
function changeTextFont() {
|
||||
const font = state.textFonts[Number(fontPicker.value)];
|
||||
if (font && !font.disabled) return changeTextFace(font);
|
||||
}
|
||||
|
||||
async function changeTextFace(font, key, value) {
|
||||
const tab = documentTab(), state = tab.state;
|
||||
const openSeq = state.openSeq;
|
||||
const callWASM = (name, ...args) => callTabWASM(tab, name, ...args);
|
||||
const item = selectedText(canvasEditor.selected);
|
||||
const font = state.textFonts[Number(fontPicker.value)];
|
||||
if (!state.ready || document.body.hasAttribute("data-busy") || !font || font.disabled) return;
|
||||
if (!state.ready || document.body.hasAttribute("data-busy")) return;
|
||||
const editing = canvasEditor.input;
|
||||
if (editing) {
|
||||
if (editing.composing) return;
|
||||
try {
|
||||
if (!await ensureRichInput() || canvasEditor.input !== editing || !isForegroundDocument(tab)) return;
|
||||
} catch (error) {
|
||||
if (canvasEditor.input === editing && isForegroundDocument(tab)) showError(error, false);
|
||||
return;
|
||||
}
|
||||
editing.rich?.remember();
|
||||
tab.editTask = true;
|
||||
const current = () => isForegroundDocument(tab) && openSeq === state.openSeq && canvasEditor.input === editing;
|
||||
if (!editing && !item) {
|
||||
const source = font || state.textDefaults.fontChoice;
|
||||
const bold = key === "bold" ? value : Boolean(state.textDefaults.bold), italic = key === "italic" ? value : Boolean(state.textDefaults.italic);
|
||||
let selected = source;
|
||||
if (source?.familyKey && (key || bold || italic)) {
|
||||
const busy = {};
|
||||
tab.editTask = busy;
|
||||
setBusy(true, "", 0, "", tab);
|
||||
try {
|
||||
const data = font.richKey && editing.rich?.imports.has(font.richKey) ? editing.rich.imports.get(font.richKey) : await readTextFont(font, tab);
|
||||
if (!isForegroundDocument(tab) || openSeq !== state.openSeq || canvasEditor.input !== editing) return;
|
||||
const [start, end] = editing.rich?.selection() || [0, editTextValue(editing.input).length];
|
||||
await callWASM("ofdgoCheckTextFont", data, editTextValue(editing.input).slice(start, end));
|
||||
if (!isForegroundDocument(tab) || openSeq !== state.openSeq || canvasEditor.input !== editing) return;
|
||||
const face = new FontFace(`ofdgo-edit-font-${crypto.randomUUID()}`, data, fontFaceStyle(data));
|
||||
await face.load();
|
||||
if (!isForegroundDocument(tab) || openSeq !== state.openSeq || canvasEditor.input !== editing) return;
|
||||
editing.fontChoice = font;
|
||||
if (item.draft) state.textDefaults.fontChoice = font;
|
||||
if (editing.rich) {
|
||||
const key = font.richKey || (font.embedded ? font.id.slice(9) : `temp-font-${crypto.randomUUID()}`);
|
||||
editing.rich.choices ||= new Map();
|
||||
editing.rich.choices.set(key, font);
|
||||
canvasEditor.setTextFont(face, font.embedded ? null : data, key);
|
||||
} else canvasEditor.setTextFont(face, data);
|
||||
} catch (err) {
|
||||
if (isForegroundDocument(tab) && openSeq === state.openSeq) {
|
||||
updateTextFonts(canvasEditor.selected, true);
|
||||
showError(err, false);
|
||||
}
|
||||
const available = source.embedded ? (state.doc?.fonts || []).filter(font => font.embedded || font.status === "matched").map(font => documentFontChoice(font.id)) : fontManager.editorFonts();
|
||||
const fonts = [source, ...available.filter(font => textFontIdentity(font) !== textFontIdentity(source))].filter(font => font.familyKey === source.familyKey && textFontSource(font) === textFontSource(source));
|
||||
const groups = await groupTextFonts(fonts, tab);
|
||||
if (!current()) return;
|
||||
const index = fonts.findIndex(choice => textFontIdentity(choice) === textFontIdentity(source));
|
||||
const faces = (groups.find(group => group.includes(index)) || []).map(index => fonts[index]);
|
||||
const result = await callTabWASM(tab, "ofdgoFontMenu", JSON.stringify({ faces: faces.map(({ bold, italic }) => ({ bold, italic })), bold, italic }));
|
||||
if (!current()) return;
|
||||
selected = faces[result.index] || (font ? source : null);
|
||||
if (!selected) throw new Error("此字体没有对应款式");
|
||||
} catch (error) {
|
||||
if (current()) showError(error, false);
|
||||
return;
|
||||
} finally {
|
||||
tab.editTask = false;
|
||||
setBusy(false, "", 0, "", tab);
|
||||
if (tab.editTask === busy) { tab.editTask = false; setBusy(false, "", 0, "", tab); }
|
||||
}
|
||||
} else if (item?.type === "TextObject") {
|
||||
if (canEditObject(item, "replaceFont") && (!font.embedded || item.font !== font.id.slice(9))) {
|
||||
const changed = await changeDocument("ofdgoStyleText", { ...item, id: (item.items || [item]).map(member => member.id) }, readTextFont(font, tab), 0, null);
|
||||
if (isForegroundDocument(tab) && (changed || openSeq === state.openSeq)) updateTextFonts(canvasEditor.selected, true);
|
||||
}
|
||||
} else {
|
||||
state.textDefaults.fontChoice = font;
|
||||
state.textDefaults.fontChoice = selected;
|
||||
state.textDefaults.bold = Boolean(bold || selected?.bold);
|
||||
state.textDefaults.italic = Boolean(italic || selected?.italic);
|
||||
updateTextFonts(null, true);
|
||||
updateObjectControls(null);
|
||||
return;
|
||||
}
|
||||
if (!editing && item?.type === "TextObject") {
|
||||
if (!key && font?.embedded && textFontRuns(item).every(run => run.format.font === font.id.slice(9))) return;
|
||||
const reflow = Boolean(key && !canEditObject(item, "layoutKnown"));
|
||||
if (!canEditObject(item, "replaceFont") || reflow && !confirmTextReflow(item)) return;
|
||||
await changeDocument("ofdgoStyleTextFonts", { ...item, id: (item.items || [item]).map(member => member.id) }, async () => {
|
||||
const result = await prepareTextFaces(tab, textFontRuns(item), font, key, value);
|
||||
return { changes: JSON.stringify(result.changes), fonts: result.fonts, reflow };
|
||||
});
|
||||
if (isForegroundDocument(tab)) updateTextFonts(canvasEditor.selected, true);
|
||||
return;
|
||||
}
|
||||
if (editing.composing) return;
|
||||
const task = (async () => {
|
||||
try {
|
||||
if (!await ensureRichInput() || !current()) return;
|
||||
const rich = editing.rich;
|
||||
rich.remember();
|
||||
const runs = rich.selectedRuns(), before = rich.model.runs, caret = rich.selection().join();
|
||||
const busy = {};
|
||||
tab.editTask = busy;
|
||||
setBusy(true, "", 0, "", tab);
|
||||
try {
|
||||
const result = await prepareTextFaces(tab, runs, font, key, value, rich);
|
||||
if (!current() || rich.model.runs !== before || rich.selection().join() !== caret || editing.composing || editing.saving) return;
|
||||
for (const entry of result.prepared) {
|
||||
const previous = rich.faces.get(entry.key);
|
||||
rich.faces.set(entry.key, entry.face);
|
||||
if (previous && ![...rich.faces.values()].includes(previous)) document.fonts.delete(previous);
|
||||
if (!entry.choice.embedded) rich.imports.set(entry.key, entry.data);
|
||||
else rich.resources.set(entry.key, entry.data);
|
||||
rich.choices.set(entry.key, entry.choice);
|
||||
document.fonts.add(entry.face);
|
||||
}
|
||||
const mapping = new Map(result.changes.map(change => [textFontStyleKey(change.from), change.to]));
|
||||
rich.apply(format => ({ ...format, ...mapping.get(textFontStyleKey(format)) }));
|
||||
if (item?.draft && font && !font.richKey) state.textDefaults.fontChoice = font;
|
||||
updateTextFonts(canvasEditor.selected, true);
|
||||
updateObjectControls(canvasEditor.selected);
|
||||
} finally {
|
||||
if (tab.editTask === busy) { tab.editTask = false; setBusy(false, "", 0, "", tab); }
|
||||
}
|
||||
} catch (error) {
|
||||
if (current()) { updateTextFonts(canvasEditor.selected, true); showError(error, false); }
|
||||
}
|
||||
})();
|
||||
if (editing) editing.fontTask = task;
|
||||
try { await task; }
|
||||
finally { if (editing?.fontTask === task) editing.fontTask = null; }
|
||||
}
|
||||
|
||||
async function insertObject(event) {
|
||||
@@ -4829,8 +5103,8 @@ async function changeDocument(name, item, ...args) {
|
||||
const scopedRichText = scoped && name === "ofdgoSaveRichText";
|
||||
if (scopedRichText) args.push(scope);
|
||||
const textSelection = name === "ofdgoUpdateTextSelection";
|
||||
if (scoped && !operation && !textSelection && !scopedRichText && name !== "ofdgoLayoutTexts") throw new Error("内部对象暂不支持此操作");
|
||||
const doc = ["ofdgoStyleText", "ofdgoFormatText", "ofdgoLayoutTexts"].includes(name)
|
||||
if (scoped && !operation && !textSelection && !scopedRichText && !["ofdgoLayoutTexts", "ofdgoStyleTextFonts"].includes(name)) throw new Error("内部对象暂不支持此操作");
|
||||
const doc = ["ofdgoStyleText", "ofdgoFormatText", "ofdgoLayoutTexts", "ofdgoStyleTextFonts"].includes(name)
|
||||
? await callWASM(name, item.index, ids, ...args)
|
||||
: textSelection
|
||||
? await callWASM(name, item.index, item.selectionIDs, ...args)
|
||||
@@ -5254,6 +5528,7 @@ async function boot() {
|
||||
updateControls();
|
||||
updateLocalFontButton();
|
||||
await fontManager.refreshPermission();
|
||||
loadEditorFonts(false);
|
||||
if (openSeq === state.openSeq) {
|
||||
setStatus(fontsRestored ? STATUS.ready : "字体读取失败");
|
||||
setBusy(false);
|
||||
@@ -6273,7 +6548,8 @@ async function changeFont(font, changes) {
|
||||
}
|
||||
|
||||
function renderFontList() {
|
||||
updateTextFonts(canvasEditor.selected, true);
|
||||
refreshEditorFonts();
|
||||
if (state.ready) loadEditorFonts(false);
|
||||
el.fontList.replaceChildren();
|
||||
const fonts = fontManager.records();
|
||||
if (!fonts.length) {
|
||||
@@ -6557,6 +6833,7 @@ async function openDocument(options = {}) {
|
||||
return;
|
||||
}
|
||||
const pageFonts = state.sourcePDF && !nativeHost && !state.editorInfo ? await fontManager.files(fontManager.records()) : fonts;
|
||||
if (state.editing) await initializeTextFont(tab);
|
||||
if (openSeq !== state.openSeq) return;
|
||||
tab.worker.configurePages?.(!state.editorInfo && !doc.encrypted && state.ofdBytes ? {
|
||||
file: state.ofdBytes, fonts: pageFonts, pdf: state.sourcePDF, fontNames: state.sourcePDF ? [...fontManager.localEntries().keys()] : [], annotations: state.renderAnnotations, document: doc.documentIndex, version: doc.documentVersion
|
||||
@@ -10592,8 +10869,9 @@ async function selectDocumentText() {
|
||||
|
||||
function copyEditorSelection(event) {
|
||||
const tab = documentTab(), state = tab.state;
|
||||
if (!state.editing || !canvasEditor.enabled || !canvasEditor.selected || !el.viewerPanel.contains(event.target)
|
||||
|| canvasEditor.input || canvasEditor.crop || canvasEditor.drag || document.body.hasAttribute("data-busy")) return false;
|
||||
if (!state.editing || !canvasEditor.enabled || !canvasEditor.selected || !editorShortcutTarget(event.target)
|
||||
|| !el.viewerPanel.contains(event.target) && !el.editorTools.contains(event.target)
|
||||
|| canvasEditor.input || canvasEditor.crop || canvasEditor.drag || document.body.hasAttribute("data-busy") || formDialogOpen()) return false;
|
||||
const items = canvasEditor.items().slice().sort((a, b) => a.order - b.order);
|
||||
const cut = event.type === "cut";
|
||||
if (!canEditObject(canvasEditor.selected, "copy") || cut && !canEditObject(canvasEditor.selected, "delete")) {
|
||||
@@ -10669,7 +10947,7 @@ function visiblePageBounds(index) {
|
||||
|
||||
async function pasteEditorContent(event) {
|
||||
const tab = documentTab(), state = tab.state;
|
||||
if (!state.editing || event.target.closest?.("input, textarea, select, [contenteditable]") || formDialogOpen()) return;
|
||||
if (!state.editing || !editorShortcutTarget(event.target) || formDialogOpen()) return;
|
||||
event.preventDefault();
|
||||
const token = event.clipboardData.getData(OBJECT_CLIPBOARD_TYPE);
|
||||
const files = Array.from(event.clipboardData.files);
|
||||
@@ -10738,7 +11016,7 @@ async function pasteEditorContent(event) {
|
||||
|
||||
function copySelection(event) {
|
||||
if (sealPreview) return;
|
||||
if (event.target.closest?.("input, textarea, [contenteditable]")) {
|
||||
if (!editorShortcutTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
const selection = document.getSelection();
|
||||
@@ -10783,7 +11061,14 @@ function copySelection(event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function focusSearch() {
|
||||
async function focusSearch(direction = 0) {
|
||||
if (canvasEditor.input || canvasEditor.crop || canvasEditor.nudge || canvasEditor.nudgeCommit) {
|
||||
const tab = documentTab(), content = documentContentKey(state);
|
||||
const current = () => isForegroundDocument(tab) && content === documentContentKey(state);
|
||||
if (!await canvasEditor.commitNudge() || !current() || !await canvasEditor.commitText() || !current()
|
||||
|| !await canvasEditor.commitCrop() || !current() || document.body.hasAttribute("data-busy") || formDialogOpen()) return;
|
||||
}
|
||||
if (direction && state.searchMatches.length) return selectSearchMatch((direction < 0 ? Math.max(0, state.searchIndex) : state.searchIndex) + direction);
|
||||
if (!state.showPages) {
|
||||
toggleSidebar("pages");
|
||||
}
|
||||
@@ -12487,6 +12772,30 @@ function updateEditorTools() {
|
||||
updateObjectControls(canvasEditor.selected);
|
||||
}
|
||||
|
||||
function updateObjectDistribution(item, disabled) {
|
||||
const tab = documentTab(), state = tab.state;
|
||||
const ids = item?.items?.slice().sort((a, b) => a.order - b.order).map(member => member.id) || [];
|
||||
if (disabled || ids.length < 3) state.objectDistribution = null;
|
||||
else {
|
||||
const { openSeq, wasmSeq } = state, revision = state.editorInfo?.revision;
|
||||
const key = JSON.stringify([openSeq, wasmSeq, revision, item.index, ids]);
|
||||
if (state.objectDistribution?.key !== key) {
|
||||
const request = state.objectDistribution = { key, axes: [] };
|
||||
const current = () => !tab.closed && state.objectDistribution === request && state.openSeq === openSeq && state.wasmSeq === wasmSeq && state.editorInfo?.revision === revision;
|
||||
callTabWASM(tab, "ofdgoDistributionAxes", item.index, ids).then(axes => {
|
||||
if (!current()) return;
|
||||
request.axes = axes;
|
||||
if (isForegroundDocument(tab)) updateObjectControls(canvasEditor.selected);
|
||||
}).catch(error => {
|
||||
if (current() && isForegroundDocument(tab)) showError(error, false);
|
||||
});
|
||||
}
|
||||
}
|
||||
const axes = state.objectDistribution?.axes || [];
|
||||
el.objectDistribute.disabled = disabled || !axes.length;
|
||||
for (const option of el.objectDistribute.options) if (option.value) option.disabled = !axes.includes(option.value);
|
||||
}
|
||||
|
||||
function updateObjectControls(item, reset = false) {
|
||||
const members = item?.items || (item ? [item] : []);
|
||||
const disabled = !item || Boolean(item.draft) || !state.ready || state.exporting;
|
||||
@@ -12504,7 +12813,7 @@ function updateObjectControls(item, reset = false) {
|
||||
el.cropImageButton.setAttribute("data-pressed", String(cropping));
|
||||
el.resetCropButton.textContent = cropping ? "取消" : "还原";
|
||||
el.resetCropButton.disabled = canEditObject(item, "resetCrop") ? disabled : el.cropImageButton.disabled || !cropping && (item.scoped ? !item.cropped : !item.imageBounds || ["x", "y", "width", "height"].every(key => Math.abs(item[key] - item.imageBounds[key]) < 1e-9));
|
||||
el.objectDistribute.disabled = el.objectAlign.disabled || !item.items || item.items.length < 3;
|
||||
updateObjectDistribution(item, el.objectAlign.disabled);
|
||||
el.editObjectButton.disabled = disabled || (item.items ? !item.items.every(member => member.type === "TextObject" && !member.richText)
|
||||
: !canEditObject(item, "children") && (item.type === "PathObject"
|
||||
|| !(item.type === "TextObject" && canEditObject(item, "rewriteText")) && !canEditObject(item, item.type === "TextObject" ? "textContent" : item.type === "ImageObject" ? "replaceImage" : "update")));
|
||||
|
||||
@@ -236,6 +236,10 @@ export class RichTextSession {
|
||||
const runs = sliceRuns(this.model.runs, start, end);
|
||||
return Object.fromEntries(FORMAT_KEYS.map(key => [key, runs.every(run => run.format[key] === runs[0]?.format[key]) ? runs[0]?.format[key] : undefined]));
|
||||
}
|
||||
selectedRuns() {
|
||||
const [start, end] = this.selection();
|
||||
return start === end ? [{ text: "", format: { ...this.typing } }] : sliceRuns(this.model.runs, start, end);
|
||||
}
|
||||
remember() {
|
||||
this.selection();
|
||||
this.entries[this.index].caret = [...this.caret];
|
||||
@@ -302,13 +306,14 @@ export class RichTextSession {
|
||||
this.remember();
|
||||
const [start, end] = this.selection();
|
||||
const before = this.model.runs;
|
||||
const apply = format => typeof values === "function" ? values(format) : { ...format, ...values };
|
||||
this.caret = this.caret[0] > this.caret[1] ? [end, start] : [start, end];
|
||||
if (start !== end) this.model.runs = normalizeRuns([
|
||||
...sliceRuns(this.model.runs, 0, start),
|
||||
...sliceRuns(this.model.runs, start, end).map(run => ({ ...run, format: { ...run.format, ...values } })),
|
||||
...sliceRuns(this.model.runs, start, end).map(run => ({ ...run, format: apply(run.format) })),
|
||||
...sliceRuns(this.model.runs, end, this.text.length),
|
||||
]);
|
||||
this.typing = { ...this.typing, ...values };
|
||||
this.typing = apply(this.typing);
|
||||
this.record(before);
|
||||
this.render();
|
||||
this.editor.options.onTextChange();
|
||||
@@ -413,7 +418,9 @@ export class RichTextSession {
|
||||
}
|
||||
setFont(face, data, key) {
|
||||
key ||= `temp-font-${crypto.randomUUID()}`;
|
||||
const previous = this.faces.get(key);
|
||||
this.faces.set(key, face);
|
||||
if (previous && ![...this.faces.values()].includes(previous)) document.fonts.delete(previous);
|
||||
if (data) this.imports.set(key, data);
|
||||
document.fonts.add(face);
|
||||
this.apply({ font: key });
|
||||
@@ -1115,6 +1122,14 @@ export class CanvasEditor {
|
||||
return true;
|
||||
}
|
||||
|
||||
selectAll(index) {
|
||||
if (!this.enabled || this.input || this.crop || this.drag || this.nudge || this.nudgeCommit || this.options.busy()) return;
|
||||
this.setTool("");
|
||||
document.getSelection()?.removeAllRanges();
|
||||
this.setSelection(this.pageItems(index));
|
||||
this.focus();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.closeText();
|
||||
this.closeCrop();
|
||||
@@ -1197,6 +1212,10 @@ export class CanvasEditor {
|
||||
rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(),
|
||||
clientX: event.clientX, clientY: event.clientY,
|
||||
};
|
||||
const extent = this.drag.extent = document.createElement("div");
|
||||
Object.assign(extent.style, { position: "absolute", left: "0", top: "0", pointerEvents: "none",
|
||||
width: `${this.viewer.scrollWidth}px`, height: `${this.viewer.scrollHeight}px` });
|
||||
this.viewer.append(extent);
|
||||
if (!this.drag.corner) {
|
||||
const selected = this.items();
|
||||
const targets = [...item.surface.querySelectorAll(".edit-object")]
|
||||
@@ -1303,6 +1322,7 @@ export class CanvasEditor {
|
||||
}
|
||||
const from = drag.origin ||= pagePoint(drag.clientX, drag.clientY, drag.rect, drag.item.page, drag.rotation);
|
||||
const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.item.page, drag.rotation);
|
||||
this.autoScroll(drag);
|
||||
if (drag.item.type === "TextObject" && (drag.corner === "w" || drag.corner === "e")) {
|
||||
if (drag.item.textFrame) {
|
||||
const frame = drag.item.textFrame, [a, b, c, d] = frame.matrix;
|
||||
@@ -1348,14 +1368,13 @@ export class CanvasEditor {
|
||||
drag.axis = event.shiftKey ? Math.abs(drag.change.x) >= Math.abs(drag.change.y) ? "x" : "y" : "";
|
||||
if (drag.axis) drag.change[drag.axis === "x" ? "y" : "x"] = 0;
|
||||
this.snap(drag, event.altKey);
|
||||
this.autoScroll(drag);
|
||||
}
|
||||
this.place(drag.item, drag.change);
|
||||
}
|
||||
|
||||
scroll() {
|
||||
const drag = this.drag;
|
||||
if (!drag || !drag.item && !drag.marquee || drag.corner || !drag.pointer) {
|
||||
if (!drag || !drag.item && !drag.marquee || !drag.pointer) {
|
||||
this.cancel();
|
||||
return;
|
||||
}
|
||||
@@ -1470,6 +1489,7 @@ export class CanvasEditor {
|
||||
}
|
||||
if (drag.corner && drag.box) {
|
||||
this.drag = null;
|
||||
drag.extent?.remove();
|
||||
this.viewer.releasePointerCapture(drag.pointerID);
|
||||
const frame = drag.item.textFrame;
|
||||
const geometry = drag.item.oriented?.box || drag.item.geometry || (frame ? { x: drag.item.x, width: textFrameBox(drag.item).width } : drag.item);
|
||||
@@ -1483,6 +1503,7 @@ export class CanvasEditor {
|
||||
}
|
||||
if (drag.change && (drag.change.x || drag.change.y || drag.change.scale !== 1)) {
|
||||
this.drag = null;
|
||||
drag.extent?.remove();
|
||||
drag.guides?.preview.remove();
|
||||
this.viewer.releasePointerCapture(drag.pointerID);
|
||||
Promise.resolve(this.options.onTransform(drag.item, drag.change)).finally(() => this.place(drag.item));
|
||||
@@ -1493,6 +1514,7 @@ export class CanvasEditor {
|
||||
const drag = this.drag;
|
||||
this.drag = null;
|
||||
if (drag) {
|
||||
drag.extent?.remove();
|
||||
for (const item of drag.erased || []) item.node.classList.remove("erasing");
|
||||
if (drag.crop) {
|
||||
this.crop.box = drag.box;
|
||||
@@ -1734,8 +1756,8 @@ export class CanvasEditor {
|
||||
return this.nudgeCommit;
|
||||
}
|
||||
|
||||
keyDown(event) {
|
||||
if (event.defaultPrevented || event.target?.closest("input, textarea, select, [contenteditable]")) return;
|
||||
keyDown(event, toolbar = false) {
|
||||
if (event.defaultPrevented || !toolbar && event.target?.closest("input, textarea, select, [contenteditable]")) return;
|
||||
if (this.crop && !this.options.busy()) {
|
||||
if (event.key === "Escape" || event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
@@ -1945,7 +1967,9 @@ export class CanvasEditor {
|
||||
}
|
||||
});
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "s") return;
|
||||
if (event.key === "F3" && !event.ctrlKey && !event.metaKey && !event.altKey) return;
|
||||
if ((event.ctrlKey || event.metaKey) && !event.altKey && (["s", "w", "tab"].includes(event.key.toLowerCase())
|
||||
|| !event.shiftKey && ["f", "o", "p"].includes(event.key.toLowerCase()))) return;
|
||||
event.stopPropagation();
|
||||
if (event.isComposing || editing.composing || editing.saving) {
|
||||
return;
|
||||
@@ -2304,12 +2328,19 @@ export class CanvasEditor {
|
||||
async commitText() {
|
||||
const editing = this.input;
|
||||
if (!editing) return true;
|
||||
if (editing.saving) return false;
|
||||
if (editing.saving || editing.preparingCommit) return false;
|
||||
if (editing.composing) {
|
||||
editing.commitAfterComposition = true;
|
||||
return false;
|
||||
}
|
||||
editing.commitAfterComposition = false;
|
||||
const preparation = this.options.prepareTextCommit?.();
|
||||
if (preparation) {
|
||||
editing.preparingCommit = true;
|
||||
try { await preparation; }
|
||||
finally { editing.preparingCommit = false; }
|
||||
if (this.input !== editing) return !this.input;
|
||||
}
|
||||
const value = editTextValue(editing.input);
|
||||
if (!this.textChanged()) {
|
||||
this.closeText();
|
||||
|
||||
+100
-23
@@ -1,17 +1,18 @@
|
||||
const DATABASE = "ofdgo";
|
||||
|
||||
export class FontPicker {
|
||||
constructor(input, toggle, list, onChange, onOpen) {
|
||||
Object.assign(this, { input, toggle, list, onChange, onOpen, fonts: [], value: "", active: -1, open: false });
|
||||
constructor(input, toggle, list, onChange, onOpen, groupFonts) {
|
||||
Object.assign(this, { input, toggle, list, onChange, onOpen, groupFonts, fonts: [], groups: [], value: "", active: -1, open: false });
|
||||
input.addEventListener("input", () => this.show(input.value));
|
||||
input.addEventListener("focus", () => input.select());
|
||||
input.addEventListener("click", () => { if (!this.open) this.show(); });
|
||||
input.addEventListener("pointerdown", event => { if (event.button === 0 && !this.open) event.preventDefault(); });
|
||||
input.addEventListener("click", () => { if (!this.open) { input.focus(); input.select(); this.show(); } });
|
||||
input.addEventListener("keydown", event => this.keyDown(event));
|
||||
input.addEventListener("blur", () => { this.commitExact(); this.close(); });
|
||||
toggle.addEventListener("pointerdown", event => event.preventDefault());
|
||||
toggle.addEventListener("click", () => {
|
||||
if (this.open) this.close();
|
||||
else { input.focus(); this.show(); }
|
||||
else { input.focus(); input.select(); this.show(); }
|
||||
});
|
||||
list.addEventListener("pointerdown", event => {
|
||||
if (event.target.closest(".font-option")) event.preventDefault();
|
||||
@@ -23,17 +24,40 @@ export class FontPicker {
|
||||
window.addEventListener("resize", () => this.close());
|
||||
}
|
||||
|
||||
setFonts(fonts, selected) {
|
||||
this.close();
|
||||
setFonts(fonts, selected, preserve = false) {
|
||||
const open = preserve && this.open;
|
||||
if (!open) this.close();
|
||||
this.fonts = fonts;
|
||||
this.groups = fonts.map((_, index) => [index]);
|
||||
this.names = new Map();
|
||||
for (const font of fonts) {
|
||||
const name = (font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
|
||||
const name = (font.menuFamily || font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
|
||||
this.names.set(name, (this.names.get(name) || 0) + 1);
|
||||
}
|
||||
const key = font => font?.id || font?.postscriptName;
|
||||
const key = font => font?.richKey || font?.id || font?.postscriptName;
|
||||
this.value = !fonts.length ? "" : selected === null ? "-1" : String(Math.max(0, fonts.findIndex(font => key(font) === key(selected))));
|
||||
this.restore();
|
||||
if (open) {
|
||||
const active = this.matches[this.active]?.font;
|
||||
this.show(this.query, false);
|
||||
const index = active ? this.matches.findIndex(({ font }) => !font.disabled && key(font) === key(active)) : -1;
|
||||
if (index >= 0) this.activate(index);
|
||||
} else this.restore();
|
||||
if (this.groupFonts) this.grouping = this.groupFonts(fonts).then(groups => {
|
||||
if (this.fonts !== fonts) return;
|
||||
this.groups = groups;
|
||||
this.names.clear();
|
||||
for (const group of groups) {
|
||||
const font = fonts[group[0]], name = (font.menuFamily || font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
|
||||
this.names.set(name, (this.names.get(name) || 0) + 1);
|
||||
}
|
||||
if (this.open) {
|
||||
const active = this.matches[this.active]?.index;
|
||||
this.show(this.query, false);
|
||||
const index = this.matches.findIndex(({ group, font }) => group.includes(active) && !font.disabled);
|
||||
if (index >= 0) this.activate(index);
|
||||
}
|
||||
else this.restore();
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
setDisabled(disabled) {
|
||||
@@ -43,23 +67,34 @@ export class FontPicker {
|
||||
|
||||
restore() {
|
||||
const font = this.fonts[Number(this.value)];
|
||||
this.input.value = font?.fullName || font?.name || "";
|
||||
const value = font?.menuFamily || font?.fullName || font?.name || "";
|
||||
if (this.input.value !== value) {
|
||||
this.input.value = value;
|
||||
if (document.activeElement === this.input) this.input.select();
|
||||
}
|
||||
this.input.scrollLeft = 0;
|
||||
this.input.title = font ? [this.input.value, this.sourceLabel(font)].filter(Boolean).join(" · ") : "";
|
||||
}
|
||||
|
||||
sourceLabel(font) {
|
||||
const name = (font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
|
||||
const name = (font.menuFamily || font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
|
||||
if (this.names.get(name) < 2) return "";
|
||||
return font.embedded ? "内嵌" : font.file || ["stored", "upload"].includes(font.source) ? "上传" : "系统";
|
||||
}
|
||||
|
||||
show(query = "", load = true) {
|
||||
if (this.input.disabled) return;
|
||||
this.query = query;
|
||||
const normalize = value => value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, " ").trim();
|
||||
const terms = normalize(query).split(" ").filter(Boolean);
|
||||
this.matches = this.fonts.map((font, index) => ({ font, index })).filter(({ font }) => {
|
||||
const name = normalize([font.fullName, font.name, font.family, font.style, font.postscriptName, ...(font.names || [])].filter(Boolean).join(" "));
|
||||
this.matches = this.groups.map(group => {
|
||||
const index = group.find(index => !this.fonts[index].bold && !this.fonts[index].italic && !this.fonts[index].disabled) ?? group[0];
|
||||
return { font: this.fonts[index], index, group };
|
||||
}).filter(({ group }) => {
|
||||
const name = normalize(group.flatMap(index => {
|
||||
const font = this.fonts[index];
|
||||
return [font.menuFamily, font.menuStyle, font.fullName, font.name, font.family, font.style, font.postscriptName, ...(font.names || [])];
|
||||
}).filter(Boolean).join(" "));
|
||||
return terms.every(term => name.includes(term));
|
||||
});
|
||||
this.list.replaceChildren();
|
||||
@@ -70,7 +105,7 @@ export class FontPicker {
|
||||
option.dataset.index = String(index);
|
||||
option.setAttribute("data-disabled", String(Boolean(font.disabled)));
|
||||
const name = document.createElement("span");
|
||||
name.textContent = font.fullName || font.name;
|
||||
name.textContent = font.menuFamily || font.fullName || font.name;
|
||||
option.append(name);
|
||||
const source = this.sourceLabel(font);
|
||||
if (source) {
|
||||
@@ -91,7 +126,7 @@ export class FontPicker {
|
||||
if (!this.open) this.list.showPopover();
|
||||
this.open = true;
|
||||
this.position();
|
||||
const selected = this.matches.findIndex(({ font, index }) => !font.disabled && String(index) === this.value);
|
||||
const selected = this.matches.findIndex(({ font, group }) => !font.disabled && group.includes(Number(this.value)));
|
||||
this.activate(selected < 0 ? this.matches.findIndex(({ font }) => !font.disabled) : selected);
|
||||
if (load) this.onOpen?.();
|
||||
}
|
||||
@@ -119,16 +154,27 @@ export class FontPicker {
|
||||
const changed = this.value !== String(index);
|
||||
this.value = String(index);
|
||||
this.close();
|
||||
if (changed) return this.onChange();
|
||||
if (changed) {
|
||||
const task = this.changing = Promise.resolve(this.onChange());
|
||||
return task.finally(() => { if (this.changing === task) this.changing = null; });
|
||||
}
|
||||
}
|
||||
|
||||
commitExact() {
|
||||
const value = this.input.value.trim().toLocaleLowerCase();
|
||||
const matches = font => font && !font.disabled
|
||||
&& [font.fullName, font.name, font.postscriptName, ...(font.names || [])].some(name => name?.toLocaleLowerCase() === value);
|
||||
&& [font.menuFamily, font.fullName, font.name, font.postscriptName, ...(font.names || [])].some(name => name?.toLocaleLowerCase() === value);
|
||||
const selected = Number(this.value);
|
||||
const index = matches(this.fonts[selected]) ? selected : this.fonts.findIndex(matches);
|
||||
if (index >= 0) this.choose(index);
|
||||
if (index >= 0) return this.choose(index);
|
||||
}
|
||||
|
||||
prepareCommit(event) {
|
||||
if (this.open && (!event || ![this.input, this.toggle, this.list].some(node => node.contains(event.target)))) {
|
||||
this.commitExact();
|
||||
this.close();
|
||||
}
|
||||
return this.changing;
|
||||
}
|
||||
|
||||
close() {
|
||||
@@ -137,7 +183,7 @@ export class FontPicker {
|
||||
}
|
||||
|
||||
keyDown(event) {
|
||||
if (event.isComposing) return;
|
||||
if (event.isComposing || event.key === "Tab" && (event.ctrlKey || event.metaKey || event.altKey)) return;
|
||||
if (!["Escape", "Enter", "ArrowDown", "ArrowUp", "Tab"].includes(event.key)) return;
|
||||
event.stopPropagation();
|
||||
if (event.key === "Escape") {
|
||||
@@ -314,13 +360,44 @@ export class FontManager {
|
||||
}), ...this.catalog];
|
||||
}
|
||||
|
||||
async loadFaces(inspect) {
|
||||
while (true) {
|
||||
async loadFaces(inspect, current = () => true) {
|
||||
while (current()) {
|
||||
const file = this.userFonts.find(font => font.enabled && !font.faces);
|
||||
if (!file) return;
|
||||
if (!file) break;
|
||||
try { file.faces = await inspect(await this.read(file)); }
|
||||
catch { file.faces = []; }
|
||||
catch { if (!current()) return; file.faces = []; }
|
||||
}
|
||||
const pending = this.catalog.filter(font => font.blob && !font.menuFamily && !font.metadataFailed);
|
||||
await Promise.all(Array.from({ length: Math.min(4, pending.length) }, async () => {
|
||||
while (current() && pending.length) {
|
||||
const font = pending.shift();
|
||||
try {
|
||||
const faces = await inspect(await this.read(font));
|
||||
const face = faces.find(face => face.postscriptName === font.postscriptName);
|
||||
if (face) Object.assign(font, { index: face.index, menuFamily: face.menuFamily, menuStyle: face.menuStyle, familyKey: face.familyKey, bold: face.bold, italic: face.italic, names: face.names });
|
||||
else font.metadataFailed = true;
|
||||
} catch { if (current()) font.metadataFailed = true; }
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async defaultFont(order, inspect, current = () => true) {
|
||||
const fonts = this.editorFonts().filter(font => !font.disabled);
|
||||
if (!fonts.length) return;
|
||||
const families = [["DengXian", "等线"], ["Microsoft YaHei", "微软雅黑"], ["PingFang SC", "苹方-简", "苹方 简体"],
|
||||
["Noto Sans SC", "Noto Sans CJK SC"], ["Source Han Sans SC", "Source Han Sans CN", "思源黑体", "思源黑体 CN"]];
|
||||
const indices = await order(fonts, families);
|
||||
let fallback = fonts.length;
|
||||
for (const index of indices) {
|
||||
if (!current()) return;
|
||||
try {
|
||||
await inspect(fonts[index], "中文汉字编辑水印,。!?");
|
||||
if (current()) return fonts[index];
|
||||
} catch (error) {
|
||||
if (error.missingGlyphs) fallback = Math.min(fallback, index);
|
||||
}
|
||||
}
|
||||
if (current()) return fonts[fallback];
|
||||
}
|
||||
|
||||
async add(fonts) {
|
||||
|
||||
@@ -292,6 +292,15 @@ export function createPageView(context) {
|
||||
unmountPage(index) {
|
||||
const shell = pageView.pageShell(index);
|
||||
if (!shell) return;
|
||||
if (context.state.editing && shell.classList.contains("rendered")) {
|
||||
const bounds = shell.getBoundingClientRect(), viewport = context.el.viewerPanel.getBoundingClientRect();
|
||||
const margin = context.state.pageWindow?.overscan ?? 600;
|
||||
const bottom = Math.max(bounds.bottom, bounds.top + shell.scrollHeight);
|
||||
if (bottom >= viewport.top - margin && bounds.top <= viewport.bottom + margin) {
|
||||
context.state.selectedPages.add(index);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const selection = document.getSelection();
|
||||
const range = !selection.isCollapsed && selection.rangeCount ? selection.getRangeAt(0) : null;
|
||||
if (context.canvasEditor.input?.item.index === index || context.canvasEditor.crop?.item.index === index
|
||||
|
||||
@@ -16,6 +16,7 @@ package font
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -23,6 +24,8 @@ import (
|
||||
)
|
||||
|
||||
// Face 字体文件中的字体名称与零起始索引,不依赖文件名
|
||||
// Family和Style为排版族,MenuFamily和MenuStyle为兼容四款式的菜单名称
|
||||
// FamilyKey区分兼容族、厂商和版本,未知时为空;Bold和Italic表示字体固有样式
|
||||
type Face struct {
|
||||
Index int `json:"index"`
|
||||
FullName string `json:"fullName"`
|
||||
@@ -30,6 +33,11 @@ type Face struct {
|
||||
Style string `json:"style"`
|
||||
PostScriptName string `json:"postscriptName"`
|
||||
Names []string `json:"names"`
|
||||
MenuFamily string `json:"menuFamily"`
|
||||
MenuStyle string `json:"menuStyle"`
|
||||
FamilyKey string `json:"familyKey"`
|
||||
Bold bool `json:"bold"`
|
||||
Italic bool `json:"italic"`
|
||||
}
|
||||
|
||||
// FaceInfo 从名称表读取字体信息
|
||||
@@ -68,7 +76,40 @@ func faceInfoLanguage(data []byte, index int, platform, language uint16) Face {
|
||||
}
|
||||
return name(ids...)
|
||||
}
|
||||
return Face{Index: index, FullName: localized(4, 6, 16, 1), Family: localized(16, 1), Style: localized(17, 2), PostScriptName: name(6), Names: fontNamesFromValues(records, values)}
|
||||
key := ""
|
||||
if family, version := name(1), name(5); family != "" && version != "" {
|
||||
key = fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join([]string{family, version, name(8)}, "\x00"))))
|
||||
}
|
||||
family, style := localized(1), localized(2)
|
||||
if family == "" {
|
||||
family = localized(16, 4, 6)
|
||||
}
|
||||
if style == "" {
|
||||
style = localized(17)
|
||||
}
|
||||
return Face{Index: index, FullName: localized(4, 6, 16, 1), Family: localized(16, 1), Style: localized(17, 2), PostScriptName: name(6), Names: fontNamesFromValues(records, values), MenuFamily: family, MenuStyle: style, FamilyKey: key}
|
||||
}
|
||||
|
||||
// TableFaceInfo 读取名称及字体固有款式,名称键不随显示语言变化
|
||||
// 入参: tables 字体表, index 集合索引, platform 平台标识, language 语言标识
|
||||
// 返回: Face 字体信息
|
||||
func TableFaceInfo(tables map[string][]byte, index int, platform, language uint16) Face {
|
||||
face := faceInfoLanguage(tables["name"], index, platform, language)
|
||||
key := face.FamilyKey
|
||||
if head := tables["head"]; len(head) >= 46 {
|
||||
key += string(head[4:8])
|
||||
style := binary.BigEndian.Uint16(head[44:])
|
||||
face.Bold, face.Italic = style&1 != 0, style&2 != 0
|
||||
}
|
||||
if os2 := tables["OS/2"]; len(os2) >= 64 {
|
||||
key += string(os2[58:62])
|
||||
style := binary.BigEndian.Uint16(os2[62:])
|
||||
face.Bold, face.Italic = style&32 != 0, style&1 != 0 || style&512 != 0
|
||||
}
|
||||
if face.FamilyKey != "" {
|
||||
face.FamilyKey = fmt.Sprintf("%x", sha256.Sum256([]byte(key)))
|
||||
}
|
||||
return face
|
||||
}
|
||||
|
||||
// FileCount 校验字体或集合头并读取字体数量
|
||||
@@ -114,7 +155,7 @@ func ReadLocalizedFaces(data []byte, platform, language uint16) ([]Face, error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
face := faceInfoLanguage(tables["name"], index, platform, language)
|
||||
face := TableFaceInfo(tables, index, platform, language)
|
||||
if face.FullName == "" {
|
||||
return nil, fmt.Errorf("font %d has no usable name", index)
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ func fontNameValues(data []byte) ([]fontNameRecord, map[[4]uint16]string) {
|
||||
values := make(map[[4]uint16]string)
|
||||
for _, record := range records {
|
||||
switch record.Name {
|
||||
case 1, 2, 4, 6, 16, 17:
|
||||
case 1, 2, 4, 5, 6, 8, 16, 17:
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
+95
-1
@@ -757,6 +757,13 @@ func (s *Session) Details() DocumentDetails {
|
||||
}
|
||||
info.SignatureCount = len(info.Signatures)
|
||||
if fonts, err := s.Renderer.DeclaredFontInfos(); err == nil {
|
||||
for index := range fonts {
|
||||
if fonts[index].Embedded {
|
||||
if face, err := s.Reader.FontFace(fonts[index].ID, 3, 0x0804); err == nil {
|
||||
fonts[index].MatchedFace = face
|
||||
}
|
||||
}
|
||||
}
|
||||
info.Fonts = fonts
|
||||
info.FontCount = len(fonts)
|
||||
}
|
||||
@@ -3172,7 +3179,37 @@ func (e *businessEngine) fontFaces(args []platformValue) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return (ofdgo.FontFile{Data: data}).Faces()
|
||||
return (ofdgo.FontFile{Data: data}).LocalizedFaces(3, 0x0804)
|
||||
}
|
||||
|
||||
// fontMenu 返回按来源隔离的字体族索引及所需款式候选
|
||||
// 入参: args 字体信息、来源及可选目标款式的JSON
|
||||
// 返回: any 分组和款式索引, error 参数错误
|
||||
func (e *businessEngine) fontMenu(args []platformValue) (any, error) {
|
||||
var request struct {
|
||||
Faces []ofdgo.FontFace `json:"faces"`
|
||||
Sources []string `json:"sources"`
|
||||
Bold bool `json:"bold"`
|
||||
Italic bool `json:"italic"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(args[0].String()), &request); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"groups": ofdgo.FontFamilyIndexes(request.Faces, request.Sources), "index": ofdgo.FontStyleIndex(request.Faces, request.Bold, request.Italic)}, nil
|
||||
}
|
||||
|
||||
// fontPreferenceOrder 按字体族偏好排列编辑字体候选索引
|
||||
// 入参: args 候选字体和字体族别名组的JSON数据
|
||||
// 返回: any 候选索引列表, error 错误信息
|
||||
func (e *businessEngine) fontPreferenceOrder(args []platformValue) (any, error) {
|
||||
var request struct {
|
||||
Faces []ofdgo.FontFace `json:"faces"`
|
||||
Families [][]string `json:"families"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(args[0].String()), &request); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ofdgo.FontPreferenceOrder(request.Faces, request.Families), nil
|
||||
}
|
||||
|
||||
// fontFace 提取编辑时选定的字体,供输入预览和保存共用
|
||||
@@ -4955,6 +4992,45 @@ func (e *businessEngine) formatText(args []platformValue) (any, error) {
|
||||
})
|
||||
}
|
||||
|
||||
// styleTextFonts 预检并原子导入各款式字体,批量替换文字字体及粗斜体
|
||||
// 入参: args 页面索引、完整路径、款式映射JSON、字体数据集合及重排标记
|
||||
// 返回: any 文档信息, error 字体或编辑错误
|
||||
func (e *businessEngine) styleTextFonts(args []platformValue) (any, error) {
|
||||
return e.changeObjects(func() error {
|
||||
paths, err := e.editorTargetPaths(e.stringsFromJS(args[1]))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var changes []ofdgo.TextFontChange
|
||||
if err := json.Unmarshal([]byte(args[2].Get("changes").String()), &changes); err != nil {
|
||||
return err
|
||||
}
|
||||
fonts := make(map[string]string)
|
||||
input := args[2].Get("fonts")
|
||||
for index := 0; index < input.Length(); index++ {
|
||||
entry := input.Index(index)
|
||||
data, err := e.bytesFromJS(entry.Get("data"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.checkFontGlyphs(data, entry.Get("text").String()); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := e.currentEditor.AddFont(FontFile{Data: data}, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fonts[entry.Get("key").String()] = id
|
||||
}
|
||||
for index := range changes {
|
||||
if id, ok := fonts[changes[index].To.Font]; ok {
|
||||
changes[index].To.Font = id
|
||||
}
|
||||
}
|
||||
return e.currentEditor.StyleTargetsFonts(args[0].Int(), paths, changes, args[2].Get("reflow").Bool())
|
||||
})
|
||||
}
|
||||
|
||||
// layoutTexts 批量修改段落增量,未指定的选项沿用各对象原值
|
||||
// 入参: args 页面索引、完整路径数组和段落JSON
|
||||
// 返回: any 文档信息, error 校验或提交错误
|
||||
@@ -5627,6 +5703,20 @@ func (e *businessEngine) orderObjects(args []platformValue) (any, error) {
|
||||
})
|
||||
}
|
||||
|
||||
// distributionAxes 获取画布选区可用的等间距分布方向
|
||||
// 入参: args 页面索引和画布对象标识数组
|
||||
// 返回: any 可用方向, error 路径或度量错误
|
||||
func (e *businessEngine) distributionAxes(args []platformValue) (any, error) {
|
||||
if e.currentEditor == nil {
|
||||
return nil, fmt.Errorf("no document is being edited")
|
||||
}
|
||||
paths, err := e.editorTargetPaths(e.stringsFromJS(args[1]))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.currentEditor.DistributionAxes(args[0].Int(), paths)
|
||||
}
|
||||
|
||||
// distributeObjects 按指定轴等距分布选区
|
||||
// 入参: args 页面索引、对象标识数组和分布轴
|
||||
// 返回: any 文档信息, error 错误信息
|
||||
@@ -8015,6 +8105,7 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an
|
||||
"ofdgoMatchFontFiles": e.matchFontFiles,
|
||||
"ofdgoChangeFontBinding": e.changeFontBinding,
|
||||
"ofdgoFontFaces": e.fontFaces,
|
||||
"ofdgoFontMenu": e.fontMenu,
|
||||
"ofdgoFontFace": e.fontFace,
|
||||
"ofdgoCreateDocument": e.createDocument,
|
||||
"ofdgoImportImages": e.importImages,
|
||||
@@ -8048,11 +8139,13 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an
|
||||
"ofdgoUpdateTextSelection": e.updateTextSelection,
|
||||
"ofdgoStyleText": e.styleText,
|
||||
"ofdgoFormatText": e.formatText,
|
||||
"ofdgoStyleTextFonts": e.styleTextFonts,
|
||||
"ofdgoGetRichText": e.getRichText,
|
||||
"ofdgoRichTextFonts": e.richTextFonts,
|
||||
"ofdgoSaveRichText": e.saveRichText,
|
||||
"ofdgoPreviewRichText": e.previewRichText,
|
||||
"ofdgoCheckTextFont": e.checkTextFont,
|
||||
"ofdgoFontPreferenceOrder": e.fontPreferenceOrder,
|
||||
"ofdgoInsertImage": e.insertImage,
|
||||
"ofdgoInsertShape": e.insertShape,
|
||||
"ofdgoUpdatePathStyle": e.updatePathStyle,
|
||||
@@ -8080,6 +8173,7 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an
|
||||
"ofdgoImportObjects": e.importObjects,
|
||||
"ofdgoOrderObjects": e.orderObjects,
|
||||
"ofdgoDistributeObjects": e.distributeObjects,
|
||||
"ofdgoDistributionAxes": e.distributionAxes,
|
||||
"ofdgoRotateObjects": e.rotateObjects,
|
||||
"ofdgoFlipObjects": e.flipObjects,
|
||||
"ofdgoCropImage": e.cropImage,
|
||||
|
||||
@@ -1049,7 +1049,7 @@ func (h *Host) readFonts() {
|
||||
continue
|
||||
}
|
||||
for _, face := range faces {
|
||||
h.fonts = append(h.fonts, map[string]any{"index": face.Index, "postscriptName": face.PostScriptName, "fullName": face.FullName, "family": face.Family, "style": face.Style, "names": face.Names, "file": file})
|
||||
h.fonts = append(h.fonts, map[string]any{"index": face.Index, "postscriptName": face.PostScriptName, "fullName": face.FullName, "family": face.Family, "style": face.Style, "names": face.Names, "menuFamily": face.MenuFamily, "menuStyle": face.MenuStyle, "familyKey": face.FamilyKey, "bold": face.Bold, "italic": face.Italic, "file": file})
|
||||
for _, alias := range append([]string{face.FullName, face.Family, face.PostScriptName, strings.TrimSpace(face.Family + " " + face.Style)}, face.Names...) {
|
||||
if alias != "" && h.fontFiles[alias+".ttf"] == "" {
|
||||
h.fontFiles[alias+".ttf"] = name + "\x00" + strconv.Itoa(face.Index)
|
||||
|
||||
@@ -808,6 +808,40 @@ func (e *Editor) ResizeTargets(page int, paths []ObjectPath, box Box) error {
|
||||
return e.TransformTargets(page, paths, Matrix{a: sx, d: sy, e: box.X - bounds.X*sx, f: box.Y - bounds.Y*sy})
|
||||
}
|
||||
|
||||
// DistributionAxes 获取选区可保持位置顺序的等间距分布方向,不修改文档或历史
|
||||
// 入参: page 页面索引, paths 按绘制顺序排列的完整路径
|
||||
// 返回: []string 可用的horizontal和vertical方向, error 路径或度量错误
|
||||
func (e *Editor) DistributionAxes(page int, paths []ObjectPath) ([]string, error) {
|
||||
objects, annotations, groups, err := editorTargetGroups(paths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var boxes []Box
|
||||
var positions []editorObjectPosition
|
||||
if len(groups) == 0 {
|
||||
boxes, positions, _, err = e.selectionGeometry(page, objects, annotations)
|
||||
} else {
|
||||
boxes, _, _, err = e.targetGeometry(page, paths)
|
||||
positions = make([]editorObjectPosition, len(paths))
|
||||
for i := range positions {
|
||||
positions[i].index = i
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
axes := []string{}
|
||||
if len(boxes) < 3 {
|
||||
return axes, nil
|
||||
}
|
||||
for _, axis := range []string{"horizontal", "vertical"} {
|
||||
if _, err := editorDistribution(boxes, positions, axis); err == nil {
|
||||
axes = append(axes, axis)
|
||||
}
|
||||
}
|
||||
return axes, nil
|
||||
}
|
||||
|
||||
// ArrangeTargets 对齐或等距分布跨容器选区,单选对齐页面
|
||||
// 入参: page 页面索引, paths 按绘制顺序排列的完整路径, distribute 是否分布, direction 对齐方向或分布轴
|
||||
// 返回: error 路径、方向或编辑错误
|
||||
@@ -886,6 +920,95 @@ func (e *Editor) FormatTargetsText(page int, paths []ObjectPath, patch TextForma
|
||||
return e.formatTargetsText(page, paths, patch, false)
|
||||
}
|
||||
|
||||
// TextFontStyle 标识文字引用的字体资源及显式粗斜体样式
|
||||
type TextFontStyle struct {
|
||||
Font string `json:"font"`
|
||||
Bold bool `json:"bold"`
|
||||
Italic bool `json:"italic"`
|
||||
}
|
||||
|
||||
// TextFontChange 描述一种原字体款式到目标字体款式的替换
|
||||
type TextFontChange struct {
|
||||
From TextFontStyle `json:"from"`
|
||||
To TextFontStyle `json:"to"`
|
||||
}
|
||||
|
||||
// StyleTargetsFonts 原子替换跨容器文字的各款式,保留混排属性及对象变换
|
||||
// 目标字体须已注册,可用Transaction将AddFont与本操作合并为一条历史记录
|
||||
// 入参: page 页面索引, paths 完整文字路径, changes 字体款式映射, reflow 允许显式重排
|
||||
// 返回: error 字体、路径或排版错误,失败时回滚全部修改
|
||||
func (e *Editor) StyleTargetsFonts(page int, paths []ObjectPath, changes []TextFontChange, reflow bool) error {
|
||||
mapping := make(map[TextFontStyle]TextFontStyle, len(changes))
|
||||
for _, change := range changes {
|
||||
if change.To.Font == "" {
|
||||
return fmt.Errorf("text requires a font")
|
||||
}
|
||||
mapping[change.From] = change.To
|
||||
}
|
||||
return e.Transaction(func(e *Editor) error {
|
||||
for _, path := range paths {
|
||||
if path.Annotation != "" {
|
||||
return ErrNotRichText
|
||||
}
|
||||
var object GraphicObject
|
||||
var err error
|
||||
if len(path.Children) == 0 {
|
||||
object, err = e.Object(page, path.ID)
|
||||
} else {
|
||||
parent := path
|
||||
parent.Children = path.Children[:len(path.Children)-1]
|
||||
var members []CompositeMember
|
||||
members, err = e.CompositeObjectsAt(page, parent, path.Children[len(path.Children)-1:])
|
||||
if err == nil {
|
||||
object = members[0].Object
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if object.Type == "TextObject" && !reflow {
|
||||
text := object.TextObject
|
||||
from := TextFontStyle{Font: text.Font, Bold: text.Weight >= 700, Italic: text.Italic}
|
||||
to, ok := mapping[from]
|
||||
if !ok || to == from {
|
||||
continue
|
||||
}
|
||||
if to.Bold == from.Bold && to.Italic == from.Italic {
|
||||
if err := e.StyleTargetsText(page, []ObjectPath{path}, TextStyle{Font: to.Font}); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
value, _, _, err := e.PrepareRichText(page, path, reflow)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changed := false
|
||||
for index := range value.Runs {
|
||||
format := &value.Runs[index].Format
|
||||
from := TextFontStyle{Font: format.Font, Bold: format.Bold, Italic: format.Italic}
|
||||
if to, ok := mapping[from]; ok && to != from {
|
||||
format.Font, format.Bold, format.Italic = to.Font, to.Bold, to.Italic
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
if reflow {
|
||||
err = e.ReflowRichText(page, path, value)
|
||||
} else {
|
||||
err = e.UpdateTargetRichText(page, path, value, 0, 0)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ReflowTargetsText 显式重排跨容器文字并应用格式增量,保留对象位置及组合结构
|
||||
// 入参: page 页面索引, paths 完整文字路径, patch 格式增量
|
||||
// 返回: error 路径、格式或排版错误
|
||||
|
||||
@@ -17,14 +17,112 @@ package ofdgo
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"unicode/utf8"
|
||||
|
||||
basefont "github.com/xiaoqidun/ofdgo/internal/base/font"
|
||||
)
|
||||
|
||||
// FontFace 字体文件中的字体名称与零起始索引,不依赖文件名
|
||||
// Family和Style保留排版族,MenuFamily和MenuStyle提供兼容四款式的菜单名称
|
||||
// FamilyKey用于同来源内的族关联,Bold和Italic表示字体固有样式
|
||||
type FontFace = basefont.Face
|
||||
|
||||
// FontFamilyIndexes 按兼容字体族、版本及来源分组,同款式重复时整族保留独立入口
|
||||
// 入参: faces 字体信息, sources 每项的来源标识,空列表表示同一来源
|
||||
// 返回: [][]int 按首次出现顺序分组的候选索引
|
||||
func FontFamilyIndexes(faces []FontFace, sources []string) [][]int {
|
||||
groups := make([][]int, 0, len(faces))
|
||||
keys := make([]string, len(faces))
|
||||
styles := make(map[string]map[[2]bool]bool)
|
||||
ambiguous := make(map[string]bool)
|
||||
for index, face := range faces {
|
||||
key := face.FamilyKey
|
||||
if len(sources) > index {
|
||||
key += "\x00" + sources[index]
|
||||
}
|
||||
keys[index] = key
|
||||
if styles[key] == nil {
|
||||
styles[key] = make(map[[2]bool]bool)
|
||||
}
|
||||
style := [2]bool{face.Bold, face.Italic}
|
||||
ambiguous[key] = ambiguous[key] || styles[key][style]
|
||||
styles[key][style] = true
|
||||
}
|
||||
indexes := make(map[string]int)
|
||||
for index, face := range faces {
|
||||
key := keys[index]
|
||||
if face.FamilyKey == "" || ambiguous[key] {
|
||||
groups = append(groups, []int{index})
|
||||
continue
|
||||
}
|
||||
group, exists := indexes[key]
|
||||
if !exists {
|
||||
group, indexes[key] = len(groups), len(groups)
|
||||
groups = append(groups, nil)
|
||||
}
|
||||
groups[group] = append(groups[group], index)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// FontStyleIndex 选择同族候选中最接近的真实款式,允许补充合成样式但不抵消固有样式
|
||||
// 入参: faces 同来源同族候选, bold 粗体, italic 斜体
|
||||
// 返回: int 候选索引,无可用款式时为-1
|
||||
func FontStyleIndex(faces []FontFace, bold, italic bool) int {
|
||||
selected, best := -1, -1
|
||||
for index, face := range faces {
|
||||
if face.Bold && !bold || face.Italic && !italic {
|
||||
continue
|
||||
}
|
||||
score := 0
|
||||
if face.Bold == bold {
|
||||
score++
|
||||
}
|
||||
if face.Italic == italic {
|
||||
score++
|
||||
}
|
||||
if score > best {
|
||||
selected, best = index, score
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
// FontPreferenceOrder 按字体族偏好排列候选索引,同族优先常规样式,其余保持原顺序
|
||||
// 入参: faces 候选字体, families 按优先级排列的字体族别名组
|
||||
// 返回: []int 候选字体的零起始索引,不修改输入列表
|
||||
func FontPreferenceOrder(faces []FontFace, families [][]string) []int {
|
||||
priorities := make(map[string]int)
|
||||
for priority, names := range families {
|
||||
for _, name := range names {
|
||||
key := FontNormalizeName(name)
|
||||
if _, exists := priorities[key]; key != "" && !exists {
|
||||
priorities[key] = priority
|
||||
}
|
||||
}
|
||||
}
|
||||
order, ranks := make([]int, len(faces)), make([]int, len(faces))
|
||||
for i, face := range faces {
|
||||
order[i], ranks[i] = i, len(families)*2
|
||||
names := append([]string{face.Family, face.FullName, face.PostScriptName}, face.Names...)
|
||||
for _, name := range names {
|
||||
if priority, exists := priorities[FontNormalizeName(name)]; exists && priority*2 < ranks[i] {
|
||||
ranks[i] = priority * 2
|
||||
}
|
||||
}
|
||||
if ranks[i] < len(families)*2 {
|
||||
switch FontNormalizeName(face.Style) {
|
||||
case "", "regular", "normal", "roman", "常规", "正常", "标准":
|
||||
default:
|
||||
ranks[i]++
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.SliceStable(order, func(i, j int) bool { return ranks[order[i]] < ranks[order[j]] })
|
||||
return order
|
||||
}
|
||||
|
||||
// FixFontDataAggressive 激进修复字体数据
|
||||
// 尝试修复缺失表(OS/2, cmap等)的TrueType字体或包装CFF裸数据
|
||||
// 对显式字形映射补充私有字符cmap,便于按glyph id渲染
|
||||
|
||||
+41
-1
@@ -107,6 +107,46 @@ func (r *Reader) FontData(id string) ([]byte, error) {
|
||||
return r.fontData(id, false)
|
||||
}
|
||||
|
||||
// FontFace 读取指定内嵌资源的本地化字体信息,集合按资源声明选取,不依赖系统字体
|
||||
// 入参: id 字体资源标识, platform 名称表平台标识, language 名称表语言标识
|
||||
// 返回: *FontFace 独立字体信息, error 资源或解析错误
|
||||
func (r *Reader) FontFace(id string, platform, language uint16) (*FontFace, error) {
|
||||
if r.fontDefinition(id) == nil {
|
||||
if _, err := r.Fonts(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
font := r.fontDefinition(id)
|
||||
if font == nil || font.FontFile == "" {
|
||||
return nil, fmt.Errorf("embedded font %q not found", id)
|
||||
}
|
||||
name := r.ResPath(font.FontFile)
|
||||
key := fmt.Sprintf("%s\x00%d:%d", name, platform, language)
|
||||
faces, ok := r.fontFaces[key]
|
||||
if !ok {
|
||||
data, err := r.readFileView(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values, err := (FontFile{Data: data}).LocalizedFaces(platform, language)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
faces = make([]*FontFace, len(values))
|
||||
for index := range values {
|
||||
faces[index] = &values[index]
|
||||
}
|
||||
r.fontFaces[key] = faces
|
||||
}
|
||||
names := make([][]string, len(faces))
|
||||
for index, face := range faces {
|
||||
names[index] = face.Names
|
||||
}
|
||||
face := *faces[basefont.NameIndex(names, []string{font.FontName, font.FamilyName}, font.Bold, font.Italic)]
|
||||
face.Names = slices.Clone(face.Names)
|
||||
return &face, nil
|
||||
}
|
||||
|
||||
// fontData 按调用方所有权读取内嵌字体,集合字体始终独立提取
|
||||
// 入参: id 字体资源标识, readonly 是否允许借用只读资源
|
||||
// 返回: []byte 字体数据, error 资源或集合错误
|
||||
@@ -251,7 +291,7 @@ func (r *Reader) embeddedFontFace(of Font) (*FontFace, error) {
|
||||
faces = make([]*FontFace, count)
|
||||
for index := range faces {
|
||||
if tables, err := basefont.FileTables(data, index); err == nil {
|
||||
info := basefont.FaceInfo(tables["name"], index)
|
||||
info := basefont.TableFaceInfo(tables, index, 0xffff, 0xffff)
|
||||
faces[index] = &info
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user