mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-30 01:27:26 +08:00
This commit is contained in:
1 parent
105590d6d7
commit
827f8ab48f
15 files changed
+768
-64
No files matched your search
@@ -90,6 +90,21 @@
|
|||||||
</select>
|
</select>
|
||||||
<span>对象</span>
|
<span>对象</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="editor-group editor-transform">
|
||||||
|
<select id="objectRotate" class="order-select" aria-label="对象旋转" disabled>
|
||||||
|
<option value="" hidden>旋转</option>
|
||||||
|
<option value="90">右转90°</option>
|
||||||
|
<option value="-90">左转90°</option>
|
||||||
|
</select>
|
||||||
|
<select id="objectFlip" class="order-select" aria-label="对象翻转" disabled>
|
||||||
|
<option value="" hidden>翻转</option>
|
||||||
|
<option value="horizontal">水平</option>
|
||||||
|
<option value="vertical">垂直</option>
|
||||||
|
</select>
|
||||||
|
<button id="cropImageButton" class="button" type="button" aria-label="裁剪图片" aria-pressed="false" disabled>裁剪</button>
|
||||||
|
<button id="resetCropButton" class="button" type="button" aria-label="还原图片" disabled>还原</button>
|
||||||
|
<span>变换</span>
|
||||||
|
</div>
|
||||||
<div class="editor-group">
|
<div class="editor-group">
|
||||||
<div class="text-font"><select id="textFont" aria-label="字体" title="字体" disabled></select><button id="textFontAdd" class="icon-button" type="button" title="添加字体" aria-label="添加字体">+</button></div>
|
<div class="text-font"><select id="textFont" aria-label="字体" title="字体" disabled></select><button id="textFontAdd" class="icon-button" type="button" title="添加字体" aria-label="添加字体">+</button></div>
|
||||||
<div class="editor-number"><input id="textSize" type="number" min="1" step="any" required aria-label="字号" disabled><span>pt</span></div>
|
<div class="editor-number"><input id="textSize" type="number" min="1" step="any" required aria-label="字号" disabled><span>pt</span></div>
|
||||||
|
|||||||
+25
-3
@@ -9,6 +9,7 @@
|
|||||||
--page: #ffffff;
|
--page: #ffffff;
|
||||||
--text-muted: #66737f;
|
--text-muted: #66737f;
|
||||||
--accent: #0f7b68;
|
--accent: #0f7b68;
|
||||||
|
--accent-soft: rgba(15, 123, 104, 0.12);
|
||||||
--accent-strong: #095c4e;
|
--accent-strong: #095c4e;
|
||||||
--focus: #0f7b68;
|
--focus: #0f7b68;
|
||||||
--sidebar-width: clamp(220px, 20vw, 292px);
|
--sidebar-width: clamp(220px, 20vw, 292px);
|
||||||
@@ -112,6 +113,7 @@ a:focus-visible {
|
|||||||
|
|
||||||
.editor-file,
|
.editor-file,
|
||||||
.editor-paragraph,
|
.editor-paragraph,
|
||||||
|
.editor-transform,
|
||||||
.editor-group:has(#textFont),
|
.editor-group:has(#textFont),
|
||||||
.editor-group:has(#shapeFill) {
|
.editor-group:has(#shapeFill) {
|
||||||
grid-template-columns: repeat(2, max-content);
|
grid-template-columns: repeat(2, max-content);
|
||||||
@@ -1220,6 +1222,7 @@ button.outline-link:hover {
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
outline: none;
|
outline: none;
|
||||||
resize: none;
|
resize: none;
|
||||||
|
transform-origin: 0 0;
|
||||||
font-kerning: none;
|
font-kerning: none;
|
||||||
font-variant-ligatures: none;
|
font-variant-ligatures: none;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
@@ -1273,7 +1276,8 @@ button.outline-link:hover {
|
|||||||
|
|
||||||
.edit-object:hover,
|
.edit-object:hover,
|
||||||
.edit-object.selected,
|
.edit-object.selected,
|
||||||
.edit-selection {
|
.edit-selection,
|
||||||
|
.edit-crop {
|
||||||
outline: calc(1px / var(--surface-scale)) solid var(--accent);
|
outline: calc(1px / var(--surface-scale)) solid var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1298,7 +1302,8 @@ button.outline-link:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.selected .edit-handle,
|
.selected .edit-handle,
|
||||||
.edit-selection .edit-handle {
|
.edit-selection .edit-handle,
|
||||||
|
.edit-crop .edit-handle {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1306,11 +1311,28 @@ button.outline-link:hover {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-selection .edit-handle {
|
.edit-selection .edit-handle,
|
||||||
|
.edit-crop .edit-handle {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-crop {
|
||||||
|
pointer-events: auto;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropping .edit-object {
|
||||||
|
pointer-events: none;
|
||||||
|
outline: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropping .edit-object .edit-handle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.edit-handle::before {
|
.edit-handle::before {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: calc(-8px / var(--surface-scale));
|
inset: calc(-8px / var(--surface-scale));
|
||||||
|
|||||||
+65
-13
@@ -93,6 +93,10 @@ const el = {
|
|||||||
objectOrder: document.querySelector("#objectOrder"),
|
objectOrder: document.querySelector("#objectOrder"),
|
||||||
objectAlign: document.querySelector("#objectAlign"),
|
objectAlign: document.querySelector("#objectAlign"),
|
||||||
objectDistribute: document.querySelector("#objectDistribute"),
|
objectDistribute: document.querySelector("#objectDistribute"),
|
||||||
|
objectRotate: document.querySelector("#objectRotate"),
|
||||||
|
objectFlip: document.querySelector("#objectFlip"),
|
||||||
|
cropImageButton: document.querySelector("#cropImageButton"),
|
||||||
|
resetCropButton: document.querySelector("#resetCropButton"),
|
||||||
multiSelectButton: document.querySelector("#multiSelectButton"),
|
multiSelectButton: document.querySelector("#multiSelectButton"),
|
||||||
textAlign: document.querySelector("#textAlign"),
|
textAlign: document.querySelector("#textAlign"),
|
||||||
textWrap: document.querySelector("#textWrap"),
|
textWrap: document.querySelector("#textWrap"),
|
||||||
@@ -240,7 +244,13 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
|
|||||||
onTransform: (item, change) => changeDocument(item.items ? "ofdgoTransformObjects" : "ofdgoTransformObject", item,
|
onTransform: (item, change) => changeDocument(item.items ? "ofdgoTransformObjects" : "ofdgoTransformObject", item,
|
||||||
change.x + item.x * (1 - change.scale), change.y + item.y * (1 - change.scale), change.scale),
|
change.x + item.x * (1 - change.scale), change.y + item.y * (1 - change.scale), change.scale),
|
||||||
onReshape: (item, box) => changeDocument("ofdgoReshapeObject", item, box.x, box.y, box.width, box.height),
|
onReshape: (item, box) => changeDocument("ofdgoReshapeObject", item, box.x, box.y, box.width, box.height),
|
||||||
onTextWidth: (item, box) => changeDocument("ofdgoLayoutText", item, box.x, box.width, true, item.align || "", item.paragraphHeight || 0),
|
onTextWidth: (item, box) => changeDocument("ofdgoLayoutText", item, box.x - item.x, box.width, true, item.align || "", item.paragraphHeight || 0),
|
||||||
|
onCrop: (item, box) => changeDocument("ofdgoCropImage", item, box.x, box.y, box.width, box.height),
|
||||||
|
onCropChange: () => {
|
||||||
|
updatePendingChanges();
|
||||||
|
updateObjectControls(canvasEditor.selected);
|
||||||
|
syncSelection();
|
||||||
|
},
|
||||||
onDelete: (item) => changeDocument(item.items ? "ofdgoDeleteObjects" : "ofdgoDeleteObject", item),
|
onDelete: (item) => changeDocument(item.items ? "ofdgoDeleteObjects" : "ofdgoDeleteObject", item),
|
||||||
onEdit: editCanvasObject,
|
onEdit: editCanvasObject,
|
||||||
drawStyle: shapeStyle,
|
drawStyle: shapeStyle,
|
||||||
@@ -253,8 +263,7 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
|
|||||||
: changeDocument("ofdgoUpdateText", item, value, fontData || null, item.size, null),
|
: changeDocument("ofdgoUpdateText", item, value, fontData || null, item.size, null),
|
||||||
onTextChange: () => {
|
onTextChange: () => {
|
||||||
const editing = canvasEditor.input;
|
const editing = canvasEditor.input;
|
||||||
setDirty(state.editing && (state.editorInfo?.revision !== state.savedRevision
|
updatePendingChanges();
|
||||||
|| Boolean(editing && (editing.input.value !== editing.item.text || editing.fontData))));
|
|
||||||
if (!editing) {
|
if (!editing) {
|
||||||
updateTextFonts(canvasEditor.selected, true);
|
updateTextFonts(canvasEditor.selected, true);
|
||||||
syncSelection();
|
syncSelection();
|
||||||
@@ -262,14 +271,22 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// updatePendingChanges 将未提交的文字和裁剪计入修改状态,不生成历史记录。
|
||||||
|
function updatePendingChanges() {
|
||||||
|
const editing = canvasEditor.input;
|
||||||
|
setDirty(state.editing && (state.editorInfo?.revision !== state.savedRevision
|
||||||
|
|| Boolean(editing && (editing.input.value !== editing.item.text || editing.fontData)) || canvasEditor.cropChanged()));
|
||||||
|
}
|
||||||
|
|
||||||
el.editorTools.addEventListener("pointerdown", (event) => {
|
el.editorTools.addEventListener("pointerdown", (event) => {
|
||||||
if (canvasEditor.input && event.target.closest("button")) event.preventDefault();
|
if (canvasEditor.input && event.target.closest("button")) event.preventDefault();
|
||||||
});
|
});
|
||||||
|
|
||||||
// editorClick commits inline text before resolving the selection for a toolbar command.
|
// editorClick 提交画布中的文字和裁剪后,再执行工具栏操作。
|
||||||
function editorClick(button, action) {
|
function editorClick(button, action) {
|
||||||
button.addEventListener("click", async () => {
|
button.addEventListener("click", async () => {
|
||||||
if (canvasEditor.input && !await canvasEditor.commitText()) return;
|
if (canvasEditor.input && !await canvasEditor.commitText()) return;
|
||||||
|
if (canvasEditor.crop && !await canvasEditor.commitCrop()) return;
|
||||||
return action();
|
return action();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -357,6 +374,30 @@ el.objectDistribute.addEventListener("change", () => {
|
|||||||
return changeDocument("ofdgoDistributeObjects", canvasEditor.selected, axis);
|
return changeDocument("ofdgoDistributeObjects", canvasEditor.selected, axis);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
for (const [select, method] of [[el.objectRotate, "ofdgoRotateObjects"], [el.objectFlip, "ofdgoFlipObjects"]]) {
|
||||||
|
select.addEventListener("change", () => {
|
||||||
|
const value = select.value, item = canvasEditor.selected;
|
||||||
|
select.value = "";
|
||||||
|
if (!item || !value || canvasEditor.crop) return;
|
||||||
|
return changeDocument(method, { ...item, id: canvasEditor.items().map(member => member.id) },
|
||||||
|
select === el.objectRotate ? Number(value) : value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
el.cropImageButton.addEventListener("click", () => {
|
||||||
|
if (document.body.hasAttribute("aria-busy")) return;
|
||||||
|
if (canvasEditor.crop) return canvasEditor.commitCrop();
|
||||||
|
const item = canvasEditor.selected;
|
||||||
|
if (item?.type === "ImageObject") canvasEditor.startCrop(item);
|
||||||
|
});
|
||||||
|
el.resetCropButton.addEventListener("click", () => {
|
||||||
|
if (document.body.hasAttribute("aria-busy")) return;
|
||||||
|
if (canvasEditor.crop) {
|
||||||
|
canvasEditor.closeCrop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const item = canvasEditor.selected;
|
||||||
|
if (item?.imageBounds) return canvasEditor.options.onCrop(item, item.imageBounds);
|
||||||
|
});
|
||||||
editorClick(el.undoButton, () => changeDocument("ofdgoUndo"));
|
editorClick(el.undoButton, () => changeDocument("ofdgoUndo"));
|
||||||
editorClick(el.redoButton, () => changeDocument("ofdgoRedo"));
|
editorClick(el.redoButton, () => changeDocument("ofdgoRedo"));
|
||||||
for (const [button, tool] of [[el.drawLineButton, "line"], [el.drawRectangleButton, "rectangle"], [el.drawEllipseButton, "ellipse"]]) {
|
for (const [button, tool] of [[el.drawLineButton, "line"], [el.drawRectangleButton, "rectangle"], [el.drawEllipseButton, "ellipse"]]) {
|
||||||
@@ -760,12 +801,12 @@ function openInsertPanel(item = null) {
|
|||||||
el.insertImage.focus();
|
el.insertImage.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// currentTextStyle returns the selected text style or the next text box's defaults.
|
// currentTextStyle 获取所选文字样式,未选中文字时使用新文字的默认样式。
|
||||||
function currentTextStyle() {
|
function currentTextStyle() {
|
||||||
return canvasEditor.selected?.type === "TextObject" ? canvasEditor.selected : state.textDefaults;
|
return canvasEditor.selected?.type === "TextObject" ? canvasEditor.selected : state.textDefaults;
|
||||||
}
|
}
|
||||||
|
|
||||||
// toggleTextTool places text directly on the page without creating empty document objects.
|
// toggleTextTool 切换画布文字工具,不提前创建空文字对象。
|
||||||
async function toggleTextTool() {
|
async function toggleTextTool() {
|
||||||
if (!state.editing || document.body.hasAttribute("aria-busy")) return;
|
if (!state.editing || document.body.hasAttribute("aria-busy")) return;
|
||||||
if (canvasEditor.tool === "text") {
|
if (canvasEditor.tool === "text") {
|
||||||
@@ -788,7 +829,7 @@ async function toggleTextTool() {
|
|||||||
el.viewerPanel.focus({ preventScroll: true });
|
el.viewerPanel.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// beginCanvasText loads the chosen local font and starts an unsaved inline text box.
|
// beginCanvasText 加载所选本地字体,并在画布上创建待提交的文字输入框。
|
||||||
async function beginCanvasText(index, box, surface, page) {
|
async function beginCanvasText(index, box, surface, page) {
|
||||||
const font = state.textDefaults.fontChoice || state.textFonts[Number(el.textFont.value)];
|
const font = state.textDefaults.fontChoice || state.textFonts[Number(el.textFont.value)];
|
||||||
if (!font) {
|
if (!font) {
|
||||||
@@ -848,7 +889,7 @@ function updateTextFonts(item, refresh = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// changeTextFont preserves uncommitted text while loading a replacement font.
|
// changeTextFont 更换字体时保留尚未提交的文字。
|
||||||
async function changeTextFont() {
|
async function changeTextFont() {
|
||||||
const item = canvasEditor.selected;
|
const item = canvasEditor.selected;
|
||||||
const font = state.textFonts[Number(el.textFont.value)];
|
const font = state.textFonts[Number(el.textFont.value)];
|
||||||
@@ -940,7 +981,7 @@ function textPoints(size) {
|
|||||||
return Number((size * 72 / 25.4).toFixed(2));
|
return Number((size * 72 / 25.4).toFixed(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
// changeParagraph keeps layout in the library and only submits changed form values.
|
// changeParagraph 仅提交变更的段落选项,具体排版由库完成。
|
||||||
async function changeParagraph(wrap = currentTextStyle().wrap) {
|
async function changeParagraph(wrap = currentTextStyle().wrap) {
|
||||||
const item = currentTextStyle();
|
const item = currentTextStyle();
|
||||||
if (document.body.hasAttribute("aria-busy") || item.draft) return;
|
if (document.body.hasAttribute("aria-busy") || item.draft) return;
|
||||||
@@ -2253,9 +2294,10 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value)
|
|||||||
if (openSeq !== state.openSeq) {
|
if (openSeq !== state.openSeq) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (canvasEditor.input) {
|
if (canvasEditor.input || canvasEditor.crop) {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
if (!await canvasEditor.commitText()) return;
|
if (!await canvasEditor.commitText()) return;
|
||||||
|
if (!await canvasEditor.commitCrop()) return;
|
||||||
openSeq = state.openSeq;
|
openSeq = state.openSeq;
|
||||||
setBusy(true, `正在生成 ${label}`, null, whole ? STATUS.exporting : STATUS.pageExporting);
|
setBusy(true, `正在生成 ${label}`, null, whole ? STATUS.exporting : STATUS.pageExporting);
|
||||||
}
|
}
|
||||||
@@ -2406,7 +2448,7 @@ function unmountPage(index) {
|
|||||||
const shell = pageShell(index);
|
const shell = pageShell(index);
|
||||||
const selection = document.getSelection();
|
const selection = document.getSelection();
|
||||||
const range = !selection.isCollapsed && selection.rangeCount ? selection.getRangeAt(0) : null;
|
const range = !selection.isCollapsed && selection.rangeCount ? selection.getRangeAt(0) : null;
|
||||||
if (canvasEditor.input?.item.index === index
|
if (canvasEditor.input?.item.index === index || canvasEditor.crop?.item.index === index
|
||||||
|| range && (!state.documentSelection || !isDocumentSelection(range)) && range.intersectsNode(shell)) {
|
|| range && (!state.documentSelection || !isDocumentSelection(range)) && range.intersectsNode(shell)) {
|
||||||
state.selectedPages.add(index);
|
state.selectedPages.add(index);
|
||||||
return;
|
return;
|
||||||
@@ -4160,7 +4202,17 @@ function updateObjectControls(item, reset = false) {
|
|||||||
const disabled = !item || Boolean(item.draft) || !state.ready || state.exporting;
|
const disabled = !item || Boolean(item.draft) || !state.ready || state.exporting;
|
||||||
el.deleteObjectButton.disabled = el.objectAlign.disabled = disabled;
|
el.deleteObjectButton.disabled = el.objectAlign.disabled = disabled;
|
||||||
el.copyObjectButton.disabled = disabled;
|
el.copyObjectButton.disabled = disabled;
|
||||||
el.objectDistribute.disabled = disabled || !item.items || item.items.length < 3;
|
const cropping = Boolean(canvasEditor.crop);
|
||||||
|
el.objectAlign.disabled = disabled || cropping;
|
||||||
|
el.objectRotate.disabled = el.objectFlip.disabled = disabled || cropping;
|
||||||
|
el.cropImageButton.disabled = disabled || item.type !== "ImageObject";
|
||||||
|
el.cropImageButton.textContent = cropping ? "完成" : "裁剪";
|
||||||
|
el.cropImageButton.setAttribute("aria-label", cropping ? "完成裁剪" : "裁剪图片");
|
||||||
|
el.cropImageButton.setAttribute("aria-pressed", String(cropping));
|
||||||
|
el.resetCropButton.textContent = cropping ? "取消" : "还原";
|
||||||
|
el.resetCropButton.setAttribute("aria-label", cropping ? "取消裁剪" : "还原图片");
|
||||||
|
el.resetCropButton.disabled = disabled || !cropping && (!item.imageBounds || ["x", "y", "width", "height"].every(key => Math.abs(item[key] - item.imageBounds[key]) < 1e-9));
|
||||||
|
el.objectDistribute.disabled = disabled || cropping || !item.items || item.items.length < 3;
|
||||||
el.editObjectButton.disabled = disabled || Boolean(item.items) || item.type === "PathObject";
|
el.editObjectButton.disabled = disabled || Boolean(item.items) || item.type === "PathObject";
|
||||||
el.multiSelectButton.disabled = !state.editing || !canvasEditor.enabled || !state.ready || state.exporting;
|
el.multiSelectButton.disabled = !state.editing || !canvasEditor.enabled || !state.ready || state.exporting;
|
||||||
const text = item?.type === "TextObject" ? item : state.textDefaults;
|
const text = item?.type === "TextObject" ? item : state.textDefaults;
|
||||||
@@ -4197,7 +4249,7 @@ function updateObjectControls(item, reset = false) {
|
|||||||
const count = members[0]?.count || 0;
|
const count = members[0]?.count || 0;
|
||||||
const canRaise = orders.some((order, i) => order !== count - orders.length + i);
|
const canRaise = orders.some((order, i) => order !== count - orders.length + i);
|
||||||
const canLower = orders.some((order, i) => order !== i);
|
const canLower = orders.some((order, i) => order !== i);
|
||||||
el.objectOrder.disabled = disabled || count <= orders.length;
|
el.objectOrder.disabled = disabled || cropping || count <= orders.length;
|
||||||
for (const option of el.objectOrder.options) {
|
for (const option of el.objectOrder.options) {
|
||||||
option.disabled = disabled || (["up", "top"].includes(option.value) ? !canRaise : !canLower);
|
option.disabled = disabled || (["up", "top"].includes(option.value) ? !canRaise : !canLower);
|
||||||
}
|
}
|
||||||
|
|||||||
+173
-17
@@ -21,14 +21,14 @@ export function objectTransform(box, dx, dy, corner = "") {
|
|||||||
return { x: left ? box.width * (1 - scale) : 0, y: top ? box.height * (1 - scale) : 0, scale };
|
return { x: left ? box.width * (1 - scale) : 0, y: top ? box.height * (1 - scale) : 0, scale };
|
||||||
}
|
}
|
||||||
|
|
||||||
// selectionBounds keeps same-page objects together without changing their relative positions.
|
// selectionBounds 计算同页对象的联合边界,不改变对象位置。
|
||||||
export function selectionBounds(items) {
|
export function selectionBounds(items) {
|
||||||
const x = Math.min(...items.map(item => item.x)), y = Math.min(...items.map(item => item.y));
|
const x = Math.min(...items.map(item => item.x)), y = Math.min(...items.map(item => item.y));
|
||||||
return { x, y, width: Math.max(...items.map(item => item.x + item.width)) - x,
|
return { x, y, width: Math.max(...items.map(item => item.x + item.width)) - x,
|
||||||
height: Math.max(...items.map(item => item.y + item.height)) - y };
|
height: Math.max(...items.map(item => item.y + item.height)) - y };
|
||||||
}
|
}
|
||||||
|
|
||||||
// alignmentGuides marks the nearest edges or centers without changing object coordinates.
|
// alignmentGuides 查找最近的边缘或中心辅助线,不改变对象坐标。
|
||||||
function alignmentGuides(box, targets, tolerance) {
|
function alignmentGuides(box, targets, tolerance) {
|
||||||
const lines = [];
|
const lines = [];
|
||||||
for (const [axis, size, cross, span] of [["x", "width", "y", "height"], ["y", "height", "x", "width"]]) {
|
for (const [axis, size, cross, span] of [["x", "width", "y", "height"], ["y", "height", "x", "width"]]) {
|
||||||
@@ -51,7 +51,26 @@ function alignmentGuides(box, targets, tolerance) {
|
|||||||
return lines.join("");
|
return lines.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
// constrainedPoint preserves shape constraints without changing off-page geometry.
|
// transformedBox 将本地矩形映射到页面,用于变换后的文字框预览。
|
||||||
|
function transformedBox(box, matrix) {
|
||||||
|
const [a, b, c, d, e, f] = matrix;
|
||||||
|
const points = [[box.x, box.y], [box.x + box.width, box.y], [box.x, box.y + box.height], [box.x + box.width, box.y + box.height]]
|
||||||
|
.map(([x, y]) => [a * x + c * y + e, b * x + d * y + f]);
|
||||||
|
const x = Math.min(...points.map(p => p[0])), y = Math.min(...points.map(p => p[1]));
|
||||||
|
return { x, y, width: Math.max(...points.map(p => p[0])) - x, height: Math.max(...points.map(p => p[1])) - y };
|
||||||
|
}
|
||||||
|
|
||||||
|
// cropBox 将保留区域限制在当前图片范围内,不改变图片比例。
|
||||||
|
function cropBox(box, bounds, dx, dy, corner) {
|
||||||
|
if (!corner) return { ...box, x: Math.max(bounds.x, Math.min(box.x + dx, bounds.x + bounds.width - box.width)),
|
||||||
|
y: Math.max(bounds.y, Math.min(box.y + dy, bounds.y + bounds.height - box.height)) };
|
||||||
|
const next = reshapeBox({ geometry: box }, dx, dy, corner, false);
|
||||||
|
const x = Math.max(bounds.x, next.x), y = Math.max(bounds.y, next.y);
|
||||||
|
return { x, y, width: Math.min(bounds.x + bounds.width, next.x + next.width) - x,
|
||||||
|
height: Math.min(bounds.y + bounds.height, next.y + next.height) - y };
|
||||||
|
}
|
||||||
|
|
||||||
|
// constrainedPoint 按修饰键约束图形端点,允许超出页面边界。
|
||||||
export function constrainedPoint(from, to, shape, shift) {
|
export function constrainedPoint(from, to, shape, shift) {
|
||||||
let dx = to.x - from.x, dy = to.y - from.y;
|
let dx = to.x - from.x, dy = to.y - from.y;
|
||||||
if (shift && shape === "line") {
|
if (shift && shape === "line") {
|
||||||
@@ -68,7 +87,7 @@ export function constrainedPoint(from, to, shape, shift) {
|
|||||||
return { x: from.x + dx, y: from.y + dy };
|
return { x: from.x + dx, y: from.y + dy };
|
||||||
}
|
}
|
||||||
|
|
||||||
// reshapeBox moves line endpoints or resizes basic shapes without scaling their stroke.
|
// reshapeBox 移动线段端点或调整基本图形范围,不缩放描边。
|
||||||
export function reshapeBox(item, dx, dy, handle, shift) {
|
export function reshapeBox(item, dx, dy, handle, shift) {
|
||||||
const box = item.geometry;
|
const box = item.geometry;
|
||||||
if (!dx && !dy && !shift) return { ...box };
|
if (!dx && !dy && !shift) return { ...box };
|
||||||
@@ -93,7 +112,7 @@ export function reshapeBox(item, dx, dy, handle, shift) {
|
|||||||
return { x, y, width: right - x, height: bottom - y };
|
return { x, y, width: right - x, height: bottom - y };
|
||||||
}
|
}
|
||||||
|
|
||||||
// paintShape uses the same page-space geometry for creation and endpoint previews.
|
// paintShape 使用统一的页面坐标绘制图形及端点调整预览。
|
||||||
function paintShape(node, shape, box) {
|
function paintShape(node, shape, box) {
|
||||||
const { x, y, width, height } = box;
|
const { x, y, width, height } = box;
|
||||||
const attributes = shape === "line" ? { x1: x, y1: y, x2: x + width, y2: y + height }
|
const attributes = shape === "line" ? { x1: x, y1: y, x2: x + width, y2: y + height }
|
||||||
@@ -152,6 +171,8 @@ export class CanvasEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mount(index, page, surface) {
|
mount(index, page, surface) {
|
||||||
|
const crop = this.crop?.item.index === index ? this.crop : null;
|
||||||
|
if (crop) this.closeCrop();
|
||||||
const pending = this.pendingSelection?.index === index;
|
const pending = this.pendingSelection?.index === index;
|
||||||
const selected = pending ? this.pendingSelection.ids || page.objects.slice(-1).map(item => item.id)
|
const selected = pending ? this.pendingSelection.ids || page.objects.slice(-1).map(item => item.id)
|
||||||
: this.items().filter(item => item.index === index).map(item => item.id);
|
: this.items().filter(item => item.index === index).map(item => item.id);
|
||||||
@@ -189,6 +210,12 @@ export class CanvasEditor {
|
|||||||
surface.append(layer);
|
surface.append(layer);
|
||||||
if (selected.length) this.setSelection(selection);
|
if (selected.length) this.setSelection(selection);
|
||||||
if (pending) this.pendingSelection = null;
|
if (pending) this.pendingSelection = null;
|
||||||
|
if (crop && selection.length === 1 && selection[0].id === crop.item.id
|
||||||
|
&& ["x", "y", "width", "height"].every(key => selection[0][key] === crop.item[key])) {
|
||||||
|
this.startCrop(selection[0]);
|
||||||
|
this.crop.box = { ...crop.box };
|
||||||
|
this.paintCrop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
place(item, change = { x: 0, y: 0, scale: 1 }) {
|
place(item, change = { x: 0, y: 0, scale: 1 }) {
|
||||||
@@ -208,9 +235,19 @@ export class CanvasEditor {
|
|||||||
left: `${(item.x + change.x) * PX_PER_MM}px`, top: `${(item.y + change.y) * PX_PER_MM}px`,
|
left: `${(item.x + change.x) * PX_PER_MM}px`, top: `${(item.y + change.y) * PX_PER_MM}px`,
|
||||||
width: `${item.width * change.scale * PX_PER_MM}px`, height: `${item.height * change.scale * PX_PER_MM}px`,
|
width: `${item.width * change.scale * PX_PER_MM}px`, height: `${item.height * change.scale * PX_PER_MM}px`,
|
||||||
});
|
});
|
||||||
|
if (item.textFrame) {
|
||||||
|
const { matrix: [a, b, c, d, e, f], width, height } = item.textFrame;
|
||||||
|
for (const handle of item.node.children) {
|
||||||
|
if (handle.dataset.corner !== "w" && handle.dataset.corner !== "e") continue;
|
||||||
|
const x = handle.dataset.corner === "e" ? width : 0;
|
||||||
|
handle.style.left = `${(a * x + c * height / 2 + e - item.x) * change.scale * PX_PER_MM}px`;
|
||||||
|
handle.style.top = `${(b * x + d * height / 2 + f - item.y) * change.scale * PX_PER_MM}px`;
|
||||||
|
handle.style.cursor = a === 0 ? "var(--resize-vertical, ns-resize)" : "var(--resize-horizontal, ew-resize)";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// placeShape positions basic-shape handles at their geometry rather than the padded boundary.
|
// placeShape 按图形几何范围放置手柄,不包含边界留量。
|
||||||
placeShape(item, box) {
|
placeShape(item, box) {
|
||||||
const x = Math.min(box.x, box.x + box.width), y = Math.min(box.y, box.y + box.height);
|
const x = Math.min(box.x, box.x + box.width), y = Math.min(box.y, box.y + box.height);
|
||||||
Object.assign(item.node.style, {
|
Object.assign(item.node.style, {
|
||||||
@@ -226,13 +263,14 @@ export class CanvasEditor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// items returns the current selection without treating a group as a document object.
|
// items 获取选中的真实对象,临时多选框不作为文档对象。
|
||||||
items() {
|
items() {
|
||||||
return this.selected?.items || (this.selected ? [this.selected] : []);
|
return this.selected?.items || (this.selected ? [this.selected] : []);
|
||||||
}
|
}
|
||||||
|
|
||||||
// setSelection updates object highlights and exposes one same-page selection to the toolbar.
|
// setSelection 更新同页对象选区,并同步工具栏。
|
||||||
setSelection(items) {
|
setSelection(items) {
|
||||||
|
if (this.crop && (items.length !== 1 || items[0] !== this.crop.item)) this.closeCrop();
|
||||||
if (this.selected?.items) this.selected.node.remove();
|
if (this.selected?.items) this.selected.node.remove();
|
||||||
for (const item of this.items()) {
|
for (const item of this.items()) {
|
||||||
item.node.classList.remove("selected", "multi-selected");
|
item.node.classList.remove("selected", "multi-selected");
|
||||||
@@ -273,6 +311,7 @@ export class CanvasEditor {
|
|||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this.closeText();
|
this.closeText();
|
||||||
|
this.closeCrop();
|
||||||
this.cancel();
|
this.cancel();
|
||||||
this.pendingSelection = null;
|
this.pendingSelection = null;
|
||||||
this.select(null);
|
this.select(null);
|
||||||
@@ -286,6 +325,19 @@ export class CanvasEditor {
|
|||||||
if (!this.enabled || this.options.busy() || event.button !== 0 || this.drag) {
|
if (!this.enabled || this.options.busy() || event.button !== 0 || this.drag) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.crop) {
|
||||||
|
if (!event.target.closest(".edit-crop")) {
|
||||||
|
this.commitCrop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
this.viewer.focus({ preventScroll: true });
|
||||||
|
const { item, box } = this.crop;
|
||||||
|
this.drag = { crop: true, box: { ...box }, pointerID: event.pointerId, corner: event.target.dataset.corner || "",
|
||||||
|
page: item.page, rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(), clientX: event.clientX, clientY: event.clientY };
|
||||||
|
this.viewer.setPointerCapture(event.pointerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.tool) {
|
if (this.tool) {
|
||||||
this.startShape(event);
|
this.startShape(event);
|
||||||
return;
|
return;
|
||||||
@@ -336,6 +388,13 @@ export class CanvasEditor {
|
|||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
drag.pointer = event;
|
drag.pointer = event;
|
||||||
|
if (drag.crop) {
|
||||||
|
const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.page, drag.rotation);
|
||||||
|
const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.page, drag.rotation);
|
||||||
|
this.crop.box = cropBox(drag.box, this.crop.item, to.x - from.x, to.y - from.y, drag.corner);
|
||||||
|
this.paintCrop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (drag.shape) {
|
if (drag.shape) {
|
||||||
this.moveShape(event, drag);
|
this.moveShape(event, drag);
|
||||||
return;
|
return;
|
||||||
@@ -350,6 +409,16 @@ export class CanvasEditor {
|
|||||||
const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.item.page, drag.rotation);
|
const from = 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);
|
const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.item.page, drag.rotation);
|
||||||
if (drag.item.type === "TextObject" && (drag.corner === "w" || drag.corner === "e")) {
|
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;
|
||||||
|
const dx = (d * (to.x - from.x) - c * (to.y - from.y)) / (a * d - b * c);
|
||||||
|
const box = reshapeBox({ geometry: { x: 0, y: 0, width: frame.width, height: frame.height } }, dx, 0, drag.corner, false);
|
||||||
|
drag.box = { x: drag.item.x + box.x, width: box.width };
|
||||||
|
const bounds = transformedBox(box, frame.matrix);
|
||||||
|
Object.assign(drag.item.node.style, { left: `${bounds.x * PX_PER_MM}px`, top: `${bounds.y * PX_PER_MM}px`,
|
||||||
|
width: `${bounds.width * PX_PER_MM}px`, height: `${bounds.height * PX_PER_MM}px` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
drag.box = reshapeBox({ geometry: drag.item }, to.x - from.x, 0, drag.corner, false);
|
drag.box = reshapeBox({ geometry: drag.item }, to.x - from.x, 0, drag.corner, false);
|
||||||
Object.assign(drag.item.node.style, { left: `${drag.box.x * PX_PER_MM}px`, width: `${drag.box.width * PX_PER_MM}px` });
|
Object.assign(drag.item.node.style, { left: `${drag.box.x * PX_PER_MM}px`, width: `${drag.box.width * PX_PER_MM}px` });
|
||||||
return;
|
return;
|
||||||
@@ -365,7 +434,7 @@ export class CanvasEditor {
|
|||||||
if (!drag.corner) this.showGuides(drag);
|
if (!drag.corner) this.showGuides(drag);
|
||||||
}
|
}
|
||||||
|
|
||||||
// showGuides keeps the visual threshold constant through page zoom and rotation.
|
// showGuides 在页面缩放和旋转时保持辅助线的屏幕判定距离一致。
|
||||||
showGuides(drag) {
|
showGuides(drag) {
|
||||||
const { item, rect, rotation, change } = drag;
|
const { item, rect, rotation, change } = drag;
|
||||||
const path = alignmentGuides({ ...item, x: item.x + change.x, y: item.y + change.y }, drag.targets, {
|
const path = alignmentGuides({ ...item, x: item.x + change.x, y: item.y + change.y }, drag.targets, {
|
||||||
@@ -388,6 +457,11 @@ export class CanvasEditor {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.move(event);
|
this.move(event);
|
||||||
|
if (drag.crop) {
|
||||||
|
this.drag = null;
|
||||||
|
this.viewer.releasePointerCapture(drag.pointerID);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (drag.marquee) {
|
if (drag.marquee) {
|
||||||
this.drag = null;
|
this.drag = null;
|
||||||
drag.preview.remove();
|
drag.preview.remove();
|
||||||
@@ -419,7 +493,7 @@ export class CanvasEditor {
|
|||||||
if ((drag.item.shape || drag.item.type === "TextObject") && drag.corner && drag.box) {
|
if ((drag.item.shape || drag.item.type === "TextObject") && drag.corner && drag.box) {
|
||||||
this.drag = null;
|
this.drag = null;
|
||||||
this.viewer.releasePointerCapture(drag.pointerID);
|
this.viewer.releasePointerCapture(drag.pointerID);
|
||||||
const geometry = drag.item.geometry || drag.item;
|
const geometry = drag.item.geometry || (drag.item.textFrame ? { x: drag.item.x, width: drag.item.textFrame.width } : drag.item);
|
||||||
const changed = Object.keys(drag.box).some((key) => drag.box[key] !== geometry[key]);
|
const changed = Object.keys(drag.box).some((key) => drag.box[key] !== geometry[key]);
|
||||||
const apply = drag.item.shape ? this.options.onReshape : this.options.onTextWidth;
|
const apply = drag.item.shape ? this.options.onReshape : this.options.onTextWidth;
|
||||||
Promise.resolve(changed && apply(drag.item, drag.box)).finally(() => {
|
Promise.resolve(changed && apply(drag.item, drag.box)).finally(() => {
|
||||||
@@ -438,6 +512,10 @@ export class CanvasEditor {
|
|||||||
const drag = this.drag;
|
const drag = this.drag;
|
||||||
this.drag = null;
|
this.drag = null;
|
||||||
if (drag) {
|
if (drag) {
|
||||||
|
if (drag.crop) {
|
||||||
|
this.crop.box = drag.box;
|
||||||
|
this.paintCrop();
|
||||||
|
}
|
||||||
drag.preview?.remove();
|
drag.preview?.remove();
|
||||||
drag.guides?.preview.remove();
|
drag.guides?.preview.remove();
|
||||||
if (drag.marquee) this.setSelection(drag.before);
|
if (drag.marquee) this.setSelection(drag.before);
|
||||||
@@ -450,7 +528,7 @@ export class CanvasEditor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startMarquee selects by page-space geometry, so rotation and zoom use the same hit tests.
|
// startMarquee 在页面坐标中开始框选,统一处理缩放和旋转。
|
||||||
startMarquee(event, additive) {
|
startMarquee(event, additive) {
|
||||||
const surface = event.target.closest(".page-surface"), page = this.pages.get(surface);
|
const surface = event.target.closest(".page-surface"), page = this.pages.get(surface);
|
||||||
if (!page) {
|
if (!page) {
|
||||||
@@ -468,7 +546,7 @@ export class CanvasEditor {
|
|||||||
this.viewer.setPointerCapture(event.pointerId);
|
this.viewer.setPointerCapture(event.pointerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// moveMarquee includes intersecting objects while preserving an additive selection.
|
// moveMarquee 选中与框选区域相交的对象,支持追加到已有选区。
|
||||||
moveMarquee(event, drag) {
|
moveMarquee(event, drag) {
|
||||||
const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.page, drag.rotation);
|
const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.page, drag.rotation);
|
||||||
const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.page, drag.rotation);
|
const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.page, drag.rotation);
|
||||||
@@ -505,7 +583,7 @@ export class CanvasEditor {
|
|||||||
this.viewer.setPointerCapture(event.pointerId);
|
this.viewer.setPointerCapture(event.pointerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// nearestSurface assigns a pasteboard gesture to one mounted page for its entire duration.
|
// nearestSurface 为页面外开始的手势确定一个已加载页面,拖动期间不切换页面。
|
||||||
nearestSurface(event) {
|
nearestSurface(event) {
|
||||||
let nearest = null, distance = Infinity;
|
let nearest = null, distance = Infinity;
|
||||||
for (const surface of this.viewer.querySelectorAll(".page-surface")) {
|
for (const surface of this.viewer.querySelectorAll(".page-surface")) {
|
||||||
@@ -521,7 +599,7 @@ export class CanvasEditor {
|
|||||||
return nearest;
|
return nearest;
|
||||||
}
|
}
|
||||||
|
|
||||||
// createPreview provides a temporary SVG overlay without rerendering the page during a drag.
|
// createPreview 创建临时SVG预览层,拖动期间不重新渲染页面。
|
||||||
createPreview(surface, page, shape, style) {
|
createPreview(surface, page, shape, style) {
|
||||||
const preview = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
const preview = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
preview.classList.add("edit-preview");
|
preview.classList.add("edit-preview");
|
||||||
@@ -553,7 +631,7 @@ export class CanvasEditor {
|
|||||||
? { x: from.x, y: from.y, width: to.x - from.x, height: to.y - from.y } : { x, y, width, height });
|
? { x: from.x, y: from.y, width: to.x - from.x, height: to.y - from.y } : { x, y, width, height });
|
||||||
}
|
}
|
||||||
|
|
||||||
// modifierChange refreshes the preview even when Shift changes without pointer movement.
|
// modifierChange 在指针静止时响应Shift变化并刷新预览。
|
||||||
modifierChange(event) {
|
modifierChange(event) {
|
||||||
const pointer = this.drag?.pointer;
|
const pointer = this.drag?.pointer;
|
||||||
if (event.key === "Shift" && pointer) {
|
if (event.key === "Shift" && pointer) {
|
||||||
@@ -564,6 +642,16 @@ export class CanvasEditor {
|
|||||||
|
|
||||||
keyDown(event) {
|
keyDown(event) {
|
||||||
if (event.defaultPrevented || event.target?.closest("input, textarea, select, [contenteditable]")) return;
|
if (event.defaultPrevented || event.target?.closest("input, textarea, select, [contenteditable]")) return;
|
||||||
|
if (this.crop && !this.options.busy()) {
|
||||||
|
if (event.key === "Escape" || event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (event.key === "Enter") this.commitCrop();
|
||||||
|
else if (this.drag) this.cancel();
|
||||||
|
else this.closeCrop();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.modifierChange(event);
|
this.modifierChange(event);
|
||||||
if (event.key === "Escape" && this.drag && !this.tool && !this.options.busy()) {
|
if (event.key === "Escape" && this.drag && !this.tool && !this.options.busy()) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -620,6 +708,11 @@ export class CanvasEditor {
|
|||||||
lineHeight: item.paragraphHeight || item.lineHeight ? `${(item.paragraphHeight || item.lineHeight) * PX_PER_MM}px` : "normal", color: item.color,
|
lineHeight: item.paragraphHeight || item.lineHeight ? `${(item.paragraphHeight || item.lineHeight) * PX_PER_MM}px` : "normal", color: item.color,
|
||||||
textAlign: item.align || "left",
|
textAlign: item.align || "left",
|
||||||
});
|
});
|
||||||
|
if (item.textFrame) {
|
||||||
|
const { width, height, matrix: [a, b, c, d, e, f] } = item.textFrame;
|
||||||
|
Object.assign(input.style, { left: "0px", top: "0px", width: `${Math.max(width, item.wrap ? width : 50) * PX_PER_MM + 4}px`,
|
||||||
|
minHeight: `${height * PX_PER_MM + 4}px`, transform: `matrix(${a},${b},${c},${d},${e * PX_PER_MM},${f * PX_PER_MM})` });
|
||||||
|
}
|
||||||
this.input = { input, item, face, fontChoice: item.fontChoice };
|
this.input = { input, item, face, fontChoice: item.fontChoice };
|
||||||
item.surface.append(input);
|
item.surface.append(input);
|
||||||
input.addEventListener("input", () => {
|
input.addEventListener("input", () => {
|
||||||
@@ -648,14 +741,14 @@ export class CanvasEditor {
|
|||||||
if (!item.draft) input.select();
|
if (!item.draft) input.select();
|
||||||
}
|
}
|
||||||
|
|
||||||
// resizeText follows the draft's current font and content without changing document geometry.
|
// resizeText 按当前字体和内容调整输入框,不修改文档中的几何范围。
|
||||||
resizeText() {
|
resizeText() {
|
||||||
const { input } = this.input;
|
const { input } = this.input;
|
||||||
input.style.height = "0px";
|
input.style.height = "0px";
|
||||||
input.style.height = `${input.scrollHeight + 2}px`;
|
input.style.height = `${input.scrollHeight + 2}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// setTextFont changes only the inline buffer until text and font are committed together.
|
// setTextFont 暂存输入框字体,后续与文字内容一起提交。
|
||||||
setTextFont(face, data) {
|
setTextFont(face, data) {
|
||||||
const editing = this.input;
|
const editing = this.input;
|
||||||
document.fonts.add(face);
|
document.fonts.add(face);
|
||||||
@@ -700,4 +793,67 @@ export class CanvasEditor {
|
|||||||
this.options.onTextChange();
|
this.options.onTextChange();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startCrop 开始调整图片保留区域,确认前不修改文档。
|
||||||
|
startCrop(item) {
|
||||||
|
this.cancel();
|
||||||
|
this.closeCrop();
|
||||||
|
this.setTool("");
|
||||||
|
const box = { x: item.x, y: item.y, width: item.width, height: item.height };
|
||||||
|
const { preview, node: mask } = this.createPreview(item.surface, item.page, "path", { fill: true, fillColor: "rgba(255,255,255,0.72)" });
|
||||||
|
mask.setAttribute("fill-rule", "evenodd");
|
||||||
|
const node = document.createElement("div");
|
||||||
|
node.className = "edit-layer edit-crop";
|
||||||
|
for (const corner of ["nw", "n", "ne", "e", "se", "s", "sw", "w"]) {
|
||||||
|
const handle = document.createElement("span");
|
||||||
|
handle.className = `edit-handle edit-${corner}`;
|
||||||
|
handle.dataset.corner = corner;
|
||||||
|
node.append(handle);
|
||||||
|
}
|
||||||
|
item.surface.append(node);
|
||||||
|
item.surface.classList.add("cropping");
|
||||||
|
this.crop = { item, box, node, mask, preview };
|
||||||
|
this.paintCrop();
|
||||||
|
this.viewer.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// paintCrop 更新遮罩及手柄,不重新渲染或复制图片数据。
|
||||||
|
paintCrop() {
|
||||||
|
const { item, box, node, mask } = this.crop;
|
||||||
|
const rectangle = b => `M${b.x} ${b.y}h${b.width}v${b.height}h${-b.width}Z`;
|
||||||
|
mask.setAttribute("d", rectangle(item) + rectangle(box));
|
||||||
|
Object.assign(node.style, { left: `${box.x * PX_PER_MM}px`, top: `${box.y * PX_PER_MM}px`, width: `${box.width * PX_PER_MM}px`, height: `${box.height * PX_PER_MM}px` });
|
||||||
|
this.options.onCropChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
// cropChanged 判断裁剪范围是否有待提交的变更。
|
||||||
|
cropChanged() {
|
||||||
|
return Boolean(this.crop && ["x", "y", "width", "height"].some(key => this.crop.box[key] !== this.crop.item[key]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// commitCrop 将裁剪作为一次可撤销操作提交,失败时保留预览。
|
||||||
|
async commitCrop() {
|
||||||
|
if (!this.crop) return true;
|
||||||
|
if (this.options.busy()) return false;
|
||||||
|
if (!this.cropChanged()) {
|
||||||
|
this.closeCrop();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const crop = this.crop;
|
||||||
|
const saved = await this.options.onCrop(crop.item, crop.box);
|
||||||
|
if (saved && this.crop === crop) this.closeCrop();
|
||||||
|
return Boolean(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeCrop 移除临时裁剪区域,不修改文档内容。
|
||||||
|
closeCrop() {
|
||||||
|
if (!this.crop) return;
|
||||||
|
if (this.drag?.crop) this.cancel();
|
||||||
|
const crop = this.crop;
|
||||||
|
this.crop = null;
|
||||||
|
crop.node.remove();
|
||||||
|
crop.preview.remove();
|
||||||
|
crop.item.surface.classList.remove("cropping");
|
||||||
|
this.options.onCropChange();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+86
-13
@@ -116,6 +116,9 @@ func RunWASM() {
|
|||||||
registerCallback("ofdgoCopyObjects", copyObjects)
|
registerCallback("ofdgoCopyObjects", copyObjects)
|
||||||
registerCallback("ofdgoOrderObjects", orderObjects)
|
registerCallback("ofdgoOrderObjects", orderObjects)
|
||||||
registerCallback("ofdgoDistributeObjects", distributeObjects)
|
registerCallback("ofdgoDistributeObjects", distributeObjects)
|
||||||
|
registerCallback("ofdgoRotateObjects", rotateObjects)
|
||||||
|
registerCallback("ofdgoFlipObjects", flipObjects)
|
||||||
|
registerCallback("ofdgoCropImage", cropImage)
|
||||||
registerCallback("ofdgoDeleteObject", deleteObject)
|
registerCallback("ofdgoDeleteObject", deleteObject)
|
||||||
registerCallback("ofdgoUndo", func([]js.Value) (any, error) { return restoreEditor(false) })
|
registerCallback("ofdgoUndo", func([]js.Value) (any, error) { return restoreEditor(false) })
|
||||||
registerCallback("ofdgoRedo", func([]js.Value) (any, error) { return restoreEditor(true) })
|
registerCallback("ofdgoRedo", func([]js.Value) (any, error) { return restoreEditor(true) })
|
||||||
@@ -572,7 +575,7 @@ func restoreEditor(redo bool) (any, error) {
|
|||||||
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
||||||
}
|
}
|
||||||
|
|
||||||
// editorObjects 提供创作画布的对象命中区域
|
// editorObjects 提供创作画布的对象范围、文字排版及原始图片边界
|
||||||
// 入参: index 页面索引, text 页面文字
|
// 入参: index 页面索引, text 页面文字
|
||||||
// 返回: []any 对象区域, error 错误信息
|
// 返回: []any 对象区域, error 错误信息
|
||||||
func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
|
func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
|
||||||
@@ -632,6 +635,13 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
|
|||||||
}
|
}
|
||||||
if box.W > 0 && box.H > 0 {
|
if box.W > 0 && box.H > 0 {
|
||||||
item := map[string]any{"id": id, "type": object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "count": len(layer.Objects)}
|
item := map[string]any{"id": id, "type": object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "count": len(layer.Objects)}
|
||||||
|
if object.Type == "ImageObject" {
|
||||||
|
full, err := object.ImageObject.ImageBounds()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item["imageBounds"] = editorBox(full)
|
||||||
|
}
|
||||||
if object.Type == "PathObject" {
|
if object.Type == "PathObject" {
|
||||||
path := object.PathObject
|
path := object.PathObject
|
||||||
kind, geometry := path.Shape()
|
kind, geometry := path.Shape()
|
||||||
@@ -652,12 +662,23 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
|
|||||||
value, layout := source.TextObject.TextLayout()
|
value, layout := source.TextObject.TextLayout()
|
||||||
item["text"], item["font"], item["fontName"], item["size"] = value, object.TextObject.Font, fontNames[object.TextObject.Font], object.TextObject.Size
|
item["text"], item["font"], item["fontName"], item["size"] = value, object.TextObject.Font, fontNames[object.TextObject.Font], object.TextObject.Size
|
||||||
item["wrap"], item["align"], item["paragraphHeight"] = layout.Wrap, layout.Align, layout.LineHeight
|
item["wrap"], item["align"], item["paragraphHeight"] = layout.Wrap, layout.Align, layout.LineHeight
|
||||||
|
frame, err := object.TextObject.TextFrame()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
boundary, _ := ofdgo.ParseBox(object.TextObject.Boundary)
|
||||||
|
matrix := ofdgo.TranslationMatrix(boundary.X, boundary.Y).Multiply(ofdgo.NewMatrix(object.TextObject.CTM))
|
||||||
|
inverse, _ := matrix.Invert()
|
||||||
|
local := inverse.TransformBox(box)
|
||||||
|
width := math.Max(object.TextObject.Size, local.X+local.W)
|
||||||
if layout.Wrap {
|
if layout.Wrap {
|
||||||
frame, err := ofdgo.ParseBox(object.TextObject.Boundary)
|
width = frame.W
|
||||||
if err != nil {
|
}
|
||||||
return nil, err
|
frame = ofdgo.Box{W: width, H: math.Max(object.TextObject.Size, local.Y+local.H)}
|
||||||
}
|
item["textFrame"] = map[string]any{"width": frame.W, "height": frame.H, "matrix": editorMatrix(matrix)}
|
||||||
item["x"], item["y"], item["width"], item["height"] = frame.X, frame.Y, frame.W, math.Max(object.TextObject.Size, box.Y+box.H-frame.Y)
|
if layout.Wrap {
|
||||||
|
bounds := matrix.TransformBox(frame)
|
||||||
|
item["x"], item["y"], item["width"], item["height"] = bounds.X, bounds.Y, bounds.W, bounds.H
|
||||||
}
|
}
|
||||||
if codes := object.TextObject.TextCode; len(codes) > 1 {
|
if codes := object.TextObject.TextCode; len(codes) > 1 {
|
||||||
first, _ := strconv.ParseFloat(codes[0].Y, 64)
|
first, _ := strconv.ParseFloat(codes[0].Y, 64)
|
||||||
@@ -815,6 +836,8 @@ func orderObjects(args []js.Value) (any, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// distributeObjects 按指定轴等距分布选区。
|
// distributeObjects 按指定轴等距分布选区。
|
||||||
|
// 入参: args 页面索引、对象标识数组和分布轴
|
||||||
|
// 返回: any 文档信息, error 错误信息
|
||||||
func distributeObjects(args []js.Value) (any, error) {
|
func distributeObjects(args []js.Value) (any, error) {
|
||||||
return changeObjects(func() error {
|
return changeObjects(func() error {
|
||||||
return currentEditor.DistributeObjects(args[0].Int(), stringsFromJS(args[1]), args[2].String())
|
return currentEditor.DistributeObjects(args[0].Int(), stringsFromJS(args[1]), args[2].String())
|
||||||
@@ -1129,7 +1152,7 @@ func insertText(args []js.Value) (any, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// layoutText 调整文字框和段落排版,保留软换行前的原文。
|
// layoutText 调整文字框和段落排版,保留软换行前的原文。
|
||||||
// 入参: args 页码、对象标识、左侧坐标、宽度、折行、对齐和行距
|
// 入参: args 页码、对象标识、本地左侧偏移、宽度、折行、对齐和行距
|
||||||
// 返回: any 文档信息, error 错误信息
|
// 返回: any 文档信息, error 错误信息
|
||||||
func layoutText(args []js.Value) (any, error) {
|
func layoutText(args []js.Value) (any, error) {
|
||||||
if currentEditor == nil {
|
if currentEditor == nil {
|
||||||
@@ -1143,14 +1166,12 @@ func layoutText(args []js.Value) (any, error) {
|
|||||||
if object.Type != "TextObject" {
|
if object.Type != "TextObject" {
|
||||||
return nil, fmt.Errorf("object is not text")
|
return nil, fmt.Errorf("object is not text")
|
||||||
}
|
}
|
||||||
box, err := ofdgo.ParseBox(object.TextObject.Boundary)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !args[2].IsNull() {
|
if !args[2].IsNull() {
|
||||||
box.X, box.W = args[2].Float(), args[3].Float()
|
object.TextObject, err = object.TextObject.ResizeTextFrame(args[2].Float(), args[3].Float())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
object.TextObject.Boundary = fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H)
|
|
||||||
value, _ := object.TextObject.TextLayout()
|
value, _ := object.TextObject.TextLayout()
|
||||||
if err := currentEditor.LayoutText(&object.TextObject, value, ofdgo.TextLayout{Wrap: args[4].Bool(), Align: args[5].String(), LineHeight: args[6].Float()}); err != nil {
|
if err := currentEditor.LayoutText(&object.TextObject, value, ofdgo.TextLayout{Wrap: args[4].Bool(), Align: args[5].String(), LineHeight: args[6].Float()}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1165,7 +1186,53 @@ func layoutText(args []js.Value) (any, error) {
|
|||||||
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// editorBox 将库层毫米边界传递给画布交互层。
|
||||||
|
// 入参: box 毫米坐标中的矩形范围
|
||||||
|
// 返回: map[string]any 可直接传递给JavaScript的横纵坐标和宽高
|
||||||
|
func editorBox(box ofdgo.Box) map[string]any {
|
||||||
|
return map[string]any{"x": box.X, "y": box.Y, "width": box.W, "height": box.H}
|
||||||
|
}
|
||||||
|
|
||||||
|
// editorMatrix 将标准CTM转换为浏览器仿射矩阵分量。
|
||||||
|
// 入参: matrix 库层仿射矩阵
|
||||||
|
// 返回: []any 可直接传递给JavaScript的a、b、c、d、e、f分量,位移单位为毫米
|
||||||
|
func editorMatrix(matrix ofdgo.Matrix) []any {
|
||||||
|
x, y := matrix.Transform(0, 0)
|
||||||
|
ax, ay := matrix.Transform(1, 0)
|
||||||
|
bx, by := matrix.Transform(0, 1)
|
||||||
|
return []any{ax - x, ay - y, bx - x, by - y, x, y}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotateObjects 旋转同页选区,通用几何与历史记录由库负责。
|
||||||
|
// 入参: args 页面索引、对象标识数组和顺时针角度
|
||||||
|
// 返回: any 文档信息, error 错误信息
|
||||||
|
func rotateObjects(args []js.Value) (any, error) {
|
||||||
|
return changeObjects(func() error {
|
||||||
|
return currentEditor.RotateObjects(args[0].Int(), stringsFromJS(args[1]), args[2].Int())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// flipObjects 镜像同页选区。
|
||||||
|
// 入参: args 页面索引、对象标识数组和镜像轴
|
||||||
|
// 返回: any 文档信息, error 错误信息
|
||||||
|
func flipObjects(args []js.Value) (any, error) {
|
||||||
|
return changeObjects(func() error {
|
||||||
|
return currentEditor.FlipObjects(args[0].Int(), stringsFromJS(args[1]), args[2].String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// cropImage 设置图片裁剪范围,保留原始图片数据。
|
||||||
|
// 入参: args 页面索引、对象标识及页面毫米坐标中的横纵坐标和宽高
|
||||||
|
// 返回: any 文档信息, error 错误信息
|
||||||
|
func cropImage(args []js.Value) (any, error) {
|
||||||
|
return changeObjects(func() error {
|
||||||
|
return currentEditor.CropImage(args[0].Int(), args[1].String(), ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float()})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// transformObjects 统一移动或缩放选区,生成一次撤销记录。
|
// transformObjects 统一移动或缩放选区,生成一次撤销记录。
|
||||||
|
// 入参: args 页面索引、对象标识数组、横纵位移和正缩放比例
|
||||||
|
// 返回: any 文档信息, error 错误信息
|
||||||
func transformObjects(args []js.Value) (any, error) {
|
func transformObjects(args []js.Value) (any, error) {
|
||||||
return changeObjects(func() error {
|
return changeObjects(func() error {
|
||||||
return currentEditor.TransformObjects(args[0].Int(), stringsFromJS(args[1]), args[2].Float(), args[3].Float(), args[4].Float())
|
return currentEditor.TransformObjects(args[0].Int(), stringsFromJS(args[1]), args[2].Float(), args[3].Float(), args[4].Float())
|
||||||
@@ -1173,6 +1240,8 @@ func transformObjects(args []js.Value) (any, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// alignObjects 对齐选区中的对象。
|
// alignObjects 对齐选区中的对象。
|
||||||
|
// 入参: args 页面索引、对象标识数组和对齐方式
|
||||||
|
// 返回: any 文档信息, error 错误信息
|
||||||
func alignObjects(args []js.Value) (any, error) {
|
func alignObjects(args []js.Value) (any, error) {
|
||||||
return changeObjects(func() error {
|
return changeObjects(func() error {
|
||||||
return currentEditor.AlignObjects(args[0].Int(), stringsFromJS(args[1]), args[2].String())
|
return currentEditor.AlignObjects(args[0].Int(), stringsFromJS(args[1]), args[2].String())
|
||||||
@@ -1180,6 +1249,8 @@ func alignObjects(args []js.Value) (any, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// deleteObjects 一次删除选区中的对象。
|
// deleteObjects 一次删除选区中的对象。
|
||||||
|
// 入参: args 页面索引和对象标识数组
|
||||||
|
// 返回: any 文档信息, error 错误信息
|
||||||
func deleteObjects(args []js.Value) (any, error) {
|
func deleteObjects(args []js.Value) (any, error) {
|
||||||
return changeObjects(func() error {
|
return changeObjects(func() error {
|
||||||
return currentEditor.DeleteObjects(args[0].Int(), stringsFromJS(args[1]))
|
return currentEditor.DeleteObjects(args[0].Int(), stringsFromJS(args[1]))
|
||||||
@@ -1187,6 +1258,8 @@ func deleteObjects(args []js.Value) (any, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// changeObjects 提交库层批量操作,无修改时复用当前预览。
|
// changeObjects 提交库层批量操作,无修改时复用当前预览。
|
||||||
|
// 入参: apply 待执行的编辑操作
|
||||||
|
// 返回: any 文档信息, error 错误信息
|
||||||
func changeObjects(apply func() error) (any, error) {
|
func changeObjects(apply func() error) (any, error) {
|
||||||
if currentEditor == nil {
|
if currentEditor == nil {
|
||||||
return nil, fmt.Errorf("no document is being created")
|
return nil, fmt.Errorf("no document is being created")
|
||||||
|
|||||||
+48
-4
@@ -352,7 +352,7 @@ func (e *Editor) AddImage(data []byte) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AddObject 按添加顺序放入正文图层,支持文字、路径和图片,自动分配对象ID
|
// AddObject 按添加顺序放入正文图层,支持文字、路径和图片,自动分配对象ID
|
||||||
// 首版支持基本颜色和直接资源引用,不支持动作、裁剪、渐变及复合图元
|
// 支持基本颜色、直接资源引用及图片的路径裁剪,不支持动作、渐变及复合图元
|
||||||
// 入参: page 页面索引, object 对象内容,添加后不再引用调用方的可变数据
|
// 入参: page 页面索引, object 对象内容,添加后不再引用调用方的可变数据
|
||||||
// 返回: string 对象标识, error 错误信息
|
// 返回: string 对象标识, error 错误信息
|
||||||
func (e *Editor) AddObject(page int, object GraphicObject) (string, error) {
|
func (e *Editor) AddObject(page int, object GraphicObject) (string, error) {
|
||||||
@@ -577,8 +577,11 @@ func (e *Editor) prepareObject(id string, object GraphicObject) (GraphicObject,
|
|||||||
} else if object.Type == "ImageObject" {
|
} else if object.Type == "ImageObject" {
|
||||||
object.ImageObject.CTM = fmt.Sprintf("%s 0 0 %s 0 0", ofdNumber(box.W), ofdNumber(box.H))
|
object.ImageObject.CTM = fmt.Sprintf("%s 0 0 %s 0 0", ofdNumber(box.W), ofdNumber(box.H))
|
||||||
}
|
}
|
||||||
if drawParam != "" || clips != nil || len(actions) != 0 {
|
if drawParam != "" || len(actions) != 0 || clips != nil && object.Type != "ImageObject" {
|
||||||
return GraphicObject{}, fmt.Errorf("draw parameter references, clips and actions are not supported for creation")
|
return GraphicObject{}, fmt.Errorf("draw parameter references, non-image clips and actions are not supported for creation")
|
||||||
|
}
|
||||||
|
if err := e.validateImageClips(clips); err != nil {
|
||||||
|
return GraphicObject{}, err
|
||||||
}
|
}
|
||||||
if alpha != nil && (*alpha < 0 || *alpha > 255) {
|
if alpha != nil && (*alpha < 0 || *alpha > 255) {
|
||||||
return GraphicObject{}, fmt.Errorf("alpha must be between 0 and 255")
|
return GraphicObject{}, fmt.Errorf("alpha must be between 0 and 255")
|
||||||
@@ -594,6 +597,42 @@ func (e *Editor) prepareObject(id string, object GraphicObject) (GraphicObject,
|
|||||||
return cloneEditorObject(object)
|
return cloneEditorObject(object)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateImageClips 校验图片的路径裁剪,仅接受无资源引用及嵌套裁剪的路径
|
||||||
|
// 入参: clips 图片裁剪集合,nil表示无裁剪
|
||||||
|
// 返回: error 错误信息
|
||||||
|
func (e *Editor) validateImageClips(clips *Clips) error {
|
||||||
|
if clips == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(clips.Clip) == 0 {
|
||||||
|
return fmt.Errorf("image clips must contain a clip")
|
||||||
|
}
|
||||||
|
for _, clip := range clips.Clip {
|
||||||
|
if len(clip.Area) == 0 {
|
||||||
|
return fmt.Errorf("image clip must contain an area")
|
||||||
|
}
|
||||||
|
for _, area := range clip.Area {
|
||||||
|
if len(area.Path) == 0 || area.DrawParam != "" || len(area.Text) != 0 {
|
||||||
|
return fmt.Errorf("image clips only support paths without draw parameter references")
|
||||||
|
}
|
||||||
|
if area.CTM != "" {
|
||||||
|
if _, err := creationNumbers(area.CTM, 6); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, path := range area.Path {
|
||||||
|
if path.ID != "" {
|
||||||
|
return fmt.Errorf("clip paths must not have object IDs")
|
||||||
|
}
|
||||||
|
if _, err := e.prepareObject("", GraphicObject{Type: "PathObject", PathObject: path}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// cloneEditorObject 通过统一序列化规则复制对象
|
// cloneEditorObject 通过统一序列化规则复制对象
|
||||||
// 入参: object 对象内容
|
// 入参: object 对象内容
|
||||||
// 返回: GraphicObject 独立副本, error 错误信息
|
// 返回: GraphicObject 独立副本, error 错误信息
|
||||||
@@ -668,6 +707,8 @@ func (e *Editor) TransformObject(page int, id string, dx, dy, scale float64) err
|
|||||||
}
|
}
|
||||||
|
|
||||||
// transformEditorObject 变换独立对象副本,保留绘制属性和编辑中的段落信息。
|
// transformEditorObject 变换独立对象副本,保留绘制属性和编辑中的段落信息。
|
||||||
|
// 入参: object 对象副本, dx 横向位移, dy 纵向位移, scale 正缩放比例
|
||||||
|
// 返回: GraphicObject 变换后的对象, error 错误信息
|
||||||
func transformEditorObject(object GraphicObject, dx, dy, scale float64) (GraphicObject, error) {
|
func transformEditorObject(object GraphicObject, dx, dy, scale float64) (GraphicObject, error) {
|
||||||
var boundary, ctm *string
|
var boundary, ctm *string
|
||||||
switch object.Type {
|
switch object.Type {
|
||||||
@@ -696,6 +737,9 @@ func transformEditorObject(object GraphicObject, dx, dy, scale float64) (Graphic
|
|||||||
boundary, ctm = &object.PathObject.Boundary, &object.PathObject.CTM
|
boundary, ctm = &object.PathObject.Boundary, &object.PathObject.CTM
|
||||||
case "ImageObject":
|
case "ImageObject":
|
||||||
boundary, ctm = &object.ImageObject.Boundary, &object.ImageObject.CTM
|
boundary, ctm = &object.ImageObject.Boundary, &object.ImageObject.CTM
|
||||||
|
if scale != 1 {
|
||||||
|
object.ImageObject.Clips = transformImageClips(object.ImageObject.Clips, Matrix{a: scale, d: scale})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
box, err := ParseBox(*boundary)
|
box, err := ParseBox(*boundary)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -780,7 +824,7 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e
|
|||||||
}
|
}
|
||||||
var width float64
|
var width float64
|
||||||
if options.Wrap || options.Align != "" && options.Align != "left" {
|
if options.Wrap || options.Align != "" && options.Align != "left" {
|
||||||
box, err := creationBox(obj.Boundary)
|
box, err := obj.TextFrame()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ func (e *Editor) DistributeObjects(page int, ids []string, axis string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// replaceObjects 替换正文对象列表,隔离历史快照与后续的增删、排序操作。
|
// replaceObjects 替换正文对象列表,隔离历史快照与后续的增删、排序操作。
|
||||||
|
// 入参: page 页面索引, objects 新的正文对象列表
|
||||||
func (e *Editor) replaceObjects(page int, objects []GraphicObject) {
|
func (e *Editor) replaceObjects(page int, objects []GraphicObject) {
|
||||||
before := e.pages[page].Content.Layer[0].Objects
|
before := e.pages[page].Content.Layer[0].Objects
|
||||||
if reflect.DeepEqual(before, objects) {
|
if reflect.DeepEqual(before, objects) {
|
||||||
@@ -319,6 +320,8 @@ func (e *Editor) DeleteObjects(page int, ids []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// selectedObjects 校验同页选择,返回内部只读对象与对应索引。
|
// selectedObjects 校验同页选择,返回内部只读对象与对应索引。
|
||||||
|
// 入参: page 页面索引, ids 对象标识,不得重复
|
||||||
|
// 返回: []GraphicObject 所选对象, []int 正文对象索引, error 错误信息
|
||||||
func (e *Editor) selectedObjects(page int, ids []string) ([]GraphicObject, []int, error) {
|
func (e *Editor) selectedObjects(page int, ids []string) ([]GraphicObject, []int, error) {
|
||||||
if _, err := e.page(page); err != nil {
|
if _, err := e.page(page); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -341,6 +344,8 @@ func (e *Editor) selectedObjects(page int, ids []string) ([]GraphicObject, []int
|
|||||||
}
|
}
|
||||||
|
|
||||||
// editorObjectID 获取可创作对象的标准标识。
|
// editorObjectID 获取可创作对象的标准标识。
|
||||||
|
// 入参: object 图形对象
|
||||||
|
// 返回: string 对象标识,不支持的类型返回空字符串
|
||||||
func editorObjectID(object GraphicObject) string {
|
func editorObjectID(object GraphicObject) string {
|
||||||
switch object.Type {
|
switch object.Type {
|
||||||
case "TextObject":
|
case "TextObject":
|
||||||
@@ -354,6 +359,8 @@ func editorObjectID(object GraphicObject) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// objectBounds 批量获取对齐范围,每页只度量一次文字。
|
// objectBounds 批量获取对齐范围,每页只度量一次文字。
|
||||||
|
// 入参: page 页面索引, objects 待度量的对象
|
||||||
|
// 返回: []Box 对象范围,文字采用字形范围,其余采用Boundary, error 错误信息
|
||||||
func (e *Editor) objectBounds(page int, objects []GraphicObject) ([]Box, error) {
|
func (e *Editor) objectBounds(page int, objects []GraphicObject) ([]Box, error) {
|
||||||
textBoxes := make(map[string]Box)
|
textBoxes := make(map[string]Box)
|
||||||
if slices.ContainsFunc(objects, func(o GraphicObject) bool { return o.Type == "TextObject" }) {
|
if slices.ContainsFunc(objects, func(o GraphicObject) bool { return o.Type == "TextObject" }) {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
"github.com/go-text/typesetting/segmenter"
|
"github.com/go-text/typesetting/segmenter"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TextLayout 横向段落选项,Wrap按Boundary宽度折行,Align为left、center、right或justify。
|
// TextLayout 本地横向段落选项,Wrap按CTM变换前的边界宽度折行,Align为left、center、right或justify。
|
||||||
// LineHeight为毫米单位的基线间距,0使用字体度量;零值保持显式换行和左对齐。
|
// LineHeight为毫米单位的基线间距,0使用字体度量;零值保持显式换行和左对齐。
|
||||||
type TextLayout struct {
|
type TextLayout struct {
|
||||||
Wrap bool
|
Wrap bool
|
||||||
@@ -45,6 +45,8 @@ func (obj TextObject) TextLayout() (string, TextLayout) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// breakTextLines 优先使用Unicode断行机会,过长词仅在字素边界折行,不丢弃空白。
|
// breakTextLines 优先使用Unicode断行机会,过长词仅在字素边界折行,不丢弃空白。
|
||||||
|
// 入参: runes 段落字符, advances 各字符步进, width 本地排版宽度, wrap 是否自动折行
|
||||||
|
// 返回: [][2]int 各行的字符起止索引,左闭右开
|
||||||
func breakTextLines(runes []rune, advances []float64, width float64, wrap bool) [][2]int {
|
func breakTextLines(runes []rune, advances []float64, width float64, wrap bool) [][2]int {
|
||||||
if !wrap || len(runes) == 0 {
|
if !wrap || len(runes) == 0 {
|
||||||
return [][2]int{{0, len(runes)}}
|
return [][2]int{{0, len(runes)}}
|
||||||
@@ -99,6 +101,8 @@ func breakTextLines(runes []rune, advances []float64, width float64, wrap bool)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// alignTextLine 将段落对齐转换为标准X和DeltaX,两端对齐保留段落末行左对齐。
|
// alignTextLine 将段落对齐转换为标准X和DeltaX,两端对齐保留段落末行左对齐。
|
||||||
|
// 入参: runes 行内字符, advances 各字符步进, width 本地排版宽度, alignment 对齐方式, justify 是否允许本行两端对齐
|
||||||
|
// 返回: float64 行首X坐标, string 字符间的DeltaX序列
|
||||||
func alignTextLine(runes []rune, advances []float64, width float64, alignment string, justify bool) (float64, string) {
|
func alignTextLine(runes []rune, advances []float64, width float64, alignment string, justify bool) (float64, string) {
|
||||||
end := len(runes)
|
end := len(runes)
|
||||||
for end > 0 && runes[end-1] == ' ' {
|
for end > 0 && runes[end-1] == ' ' {
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package ofdgo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RotateObject 绕对象中心旋转,文字采用字形范围,其余对象采用Boundary。
|
||||||
|
// 入参: page 页面索引, id 对象标识, degrees 顺时针角度
|
||||||
|
// 返回: error 错误信息
|
||||||
|
func (e *Editor) RotateObject(page int, id string, degrees int) error {
|
||||||
|
return e.RotateObjects(page, []string{id}, degrees)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RotateObjects 绕同页选区中心旋转,文字采用字形范围,其余对象采用Boundary。
|
||||||
|
// 保留相对位置及资源,一次撤销恢复全部。
|
||||||
|
// 入参: page 页面索引, ids 对象标识, degrees 顺时针角度,仅支持90度的整数倍
|
||||||
|
// 返回: error 错误信息
|
||||||
|
func (e *Editor) RotateObjects(page int, ids []string, degrees int) error {
|
||||||
|
if degrees%90 != 0 {
|
||||||
|
return fmt.Errorf("rotation must be a multiple of 90 degrees")
|
||||||
|
}
|
||||||
|
turn := (degrees%360 + 360) % 360 / 90
|
||||||
|
m := []Matrix{IdentityMatrix, {b: 1, c: -1}, {a: -1, d: -1}, {b: -1, c: 1}}[turn]
|
||||||
|
return e.orientObjects(page, ids, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlipObject 绕对象中心镜像,文字采用字形范围,其余对象采用Boundary。
|
||||||
|
// 入参: page 页面索引, id 对象标识, axis 为horizontal或vertical
|
||||||
|
// 返回: error 错误信息
|
||||||
|
func (e *Editor) FlipObject(page int, id, axis string) error {
|
||||||
|
return e.FlipObjects(page, []string{id}, axis)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlipObjects 以同页选区中心镜像,保留资源及绘制顺序,一次撤销恢复全部。
|
||||||
|
// 文字采用字形范围,其余对象采用Boundary。
|
||||||
|
// 入参: page 页面索引, ids 对象标识, axis 为horizontal或vertical
|
||||||
|
// 返回: error 错误信息
|
||||||
|
func (e *Editor) FlipObjects(page int, ids []string, axis string) error {
|
||||||
|
m := IdentityMatrix
|
||||||
|
switch axis {
|
||||||
|
case "horizontal":
|
||||||
|
m.a = -1
|
||||||
|
case "vertical":
|
||||||
|
m.d = -1
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid flip axis %q", axis)
|
||||||
|
}
|
||||||
|
return e.orientObjects(page, ids, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// orientObjects 更新Boundary与CTM,不重排文字或重采样图片。
|
||||||
|
// 入参: page 页面索引, ids 对象标识, matrix 直角旋转或镜像矩阵
|
||||||
|
// 返回: error 错误信息
|
||||||
|
func (e *Editor) orientObjects(page int, ids []string, matrix Matrix) error {
|
||||||
|
objects, _, err := e.selectedObjects(page, ids)
|
||||||
|
if err != nil || len(objects) == 0 || matrix == IdentityMatrix {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
boxes, err := e.objectBounds(page, objects)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var box Box
|
||||||
|
for _, item := range boxes {
|
||||||
|
box = unionTextBox(box, item)
|
||||||
|
}
|
||||||
|
x, y := box.X+box.W/2, box.Y+box.H/2
|
||||||
|
matrix = TranslationMatrix(x, y).Multiply(matrix).Multiply(TranslationMatrix(-x, -y))
|
||||||
|
for i, object := range objects {
|
||||||
|
var boundary, ctm *string
|
||||||
|
switch object.Type {
|
||||||
|
case "TextObject":
|
||||||
|
boundary, ctm = &object.TextObject.Boundary, &object.TextObject.CTM
|
||||||
|
case "PathObject":
|
||||||
|
boundary, ctm = &object.PathObject.Boundary, &object.PathObject.CTM
|
||||||
|
case "ImageObject":
|
||||||
|
boundary, ctm = &object.ImageObject.Boundary, &object.ImageObject.CTM
|
||||||
|
}
|
||||||
|
before, _ := ParseBox(*boundary)
|
||||||
|
after := matrix.TransformBox(before)
|
||||||
|
local := TranslationMatrix(-after.X, -after.Y).Multiply(matrix).Multiply(TranslationMatrix(before.X, before.Y))
|
||||||
|
*ctm = local.Multiply(NewMatrix(*ctm)).String()
|
||||||
|
*boundary = editorBoxString(after)
|
||||||
|
if object.Type == "ImageObject" {
|
||||||
|
object.ImageObject.Clips = transformImageClips(object.ImageObject.Clips, local)
|
||||||
|
}
|
||||||
|
objects[i] = object
|
||||||
|
}
|
||||||
|
return e.UpdateObjects(page, objects)
|
||||||
|
}
|
||||||
|
|
||||||
|
// transformImageClips 同步变换不随对象CTM变化的裁剪区域,不修改原始裁剪数据。
|
||||||
|
// 入参: clips 图片裁剪集合, matrix 对象边界坐标中的变换
|
||||||
|
// 返回: *Clips 变换后的裁剪集合
|
||||||
|
func transformImageClips(clips *Clips, matrix Matrix) *Clips {
|
||||||
|
if clips == nil || clips.TransFlag == nil || *clips.TransFlag {
|
||||||
|
return clips
|
||||||
|
}
|
||||||
|
result := *clips
|
||||||
|
result.Clip = slices.Clone(clips.Clip)
|
||||||
|
for i := range result.Clip {
|
||||||
|
result.Clip[i].Area = slices.Clone(clips.Clip[i].Area)
|
||||||
|
for j := range result.Clip[i].Area {
|
||||||
|
area := &result.Clip[i].Area[j]
|
||||||
|
area.CTM = matrix.Multiply(NewMatrix(area.CTM)).String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageBounds 返回完整图片经Boundary位移及CTM变换后的轴对齐边界,不应用裁剪或父级变换。
|
||||||
|
// 返回: Box 所在坐标系中的完整图片边界, error 错误信息
|
||||||
|
func (obj ImageObject) ImageBounds() (Box, error) {
|
||||||
|
box, err := creationBox(obj.Boundary)
|
||||||
|
if err != nil {
|
||||||
|
return Box{}, err
|
||||||
|
}
|
||||||
|
if obj.CTM == "" {
|
||||||
|
return box, nil
|
||||||
|
}
|
||||||
|
if _, err := creationNumbers(obj.CTM, 6); err != nil {
|
||||||
|
return Box{}, err
|
||||||
|
}
|
||||||
|
return TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(obj.CTM)).TransformBox(Box{W: 1, H: 1}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CropImage 按页面毫米坐标重设图片裁剪,保留原始资源及像素,可再次扩大裁剪区域。
|
||||||
|
// 使用ImageObject.ImageBounds返回的范围可还原完整图片;提交一次撤销记录。
|
||||||
|
// 入参: page 页面索引, id 图片对象标识, box 页面毫米坐标中的保留范围
|
||||||
|
// 返回: error 错误信息
|
||||||
|
func (e *Editor) CropImage(page int, id string, box Box) error {
|
||||||
|
object, err := e.Object(page, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if object.Type != "ImageObject" {
|
||||||
|
return fmt.Errorf("object %q is not an image", id)
|
||||||
|
}
|
||||||
|
image := &object.ImageObject
|
||||||
|
full, err := image.ImageBounds()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := creationBox(editorBoxString(box)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if box.X < full.X-1e-9 || box.Y < full.Y-1e-9 || box.X+box.W > full.X+full.W+1e-9 || box.Y+box.H > full.Y+full.H+1e-9 {
|
||||||
|
return fmt.Errorf("crop must stay within the image bounds")
|
||||||
|
}
|
||||||
|
before, _ := ParseBox(image.Boundary)
|
||||||
|
if box.X != before.X || box.Y != before.Y {
|
||||||
|
image.CTM = TranslationMatrix(before.X-box.X, before.Y-box.Y).Multiply(NewMatrix(image.CTM)).String()
|
||||||
|
}
|
||||||
|
image.Boundary = editorBoxString(box)
|
||||||
|
image.Clips = nil
|
||||||
|
if box != full {
|
||||||
|
inverse, ok := NewMatrix(image.CTM).Invert()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("image transform is not invertible")
|
||||||
|
}
|
||||||
|
path, err := NewShape(ShapeRectangle, Box{W: box.W, H: box.H})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fill, stroke := true, false
|
||||||
|
path.Fill, path.Stroke = &fill, &stroke
|
||||||
|
image.Clips = &Clips{Clip: []Clip{{Area: []ClipArea{{CTM: inverse.String(), Path: []PathObject{path}}}}}}
|
||||||
|
}
|
||||||
|
return e.UpdateObject(page, id, object)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextFrame 将对象Boundary逆变换到本地坐标并返回轴对齐边界。
|
||||||
|
// 直角旋转与翻转不改变本地段落宽度。
|
||||||
|
// 返回: Box 对象本地坐标中的边界, error 错误信息
|
||||||
|
func (obj TextObject) TextFrame() (Box, error) {
|
||||||
|
box, err := creationBox(obj.Boundary)
|
||||||
|
if err != nil {
|
||||||
|
return Box{}, err
|
||||||
|
}
|
||||||
|
if obj.CTM != "" {
|
||||||
|
if _, err := creationNumbers(obj.CTM, 6); err != nil {
|
||||||
|
return Box{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m, ok := NewMatrix(obj.CTM).Invert()
|
||||||
|
if !ok {
|
||||||
|
return Box{}, fmt.Errorf("text transform is not invertible")
|
||||||
|
}
|
||||||
|
return m.TransformBox(Box{W: box.W, H: box.H}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResizeTextFrame 调整文字本地坐标的左侧偏移及宽度,保留页面方向;重新排版后通过UpdateObject提交。
|
||||||
|
// 支持轴向缩放、直角旋转与镜像,不支持斜切或其他角度旋转。
|
||||||
|
// 入参: offset 本地左侧位移, width 新的本地排版宽度,单位为毫米
|
||||||
|
// 返回: TextObject 调整后的文字对象, error 错误信息
|
||||||
|
func (obj TextObject) ResizeTextFrame(offset, width float64) (TextObject, error) {
|
||||||
|
frame, err := obj.TextFrame()
|
||||||
|
if err != nil {
|
||||||
|
return TextObject{}, err
|
||||||
|
}
|
||||||
|
if !axisAlignedMatrix(NewMatrix(obj.CTM)) {
|
||||||
|
return TextObject{}, fmt.Errorf("text frame resizing requires an axis-aligned transform")
|
||||||
|
}
|
||||||
|
if !finite(offset) || !finite(width) || width <= 0 {
|
||||||
|
return TextObject{}, fmt.Errorf("text frame requires a finite offset and a positive width")
|
||||||
|
}
|
||||||
|
boundary, _ := ParseBox(obj.Boundary)
|
||||||
|
m := TranslationMatrix(boundary.X, boundary.Y).Multiply(NewMatrix(obj.CTM))
|
||||||
|
frame.X += offset
|
||||||
|
frame.W = width
|
||||||
|
box := m.TransformBox(frame)
|
||||||
|
obj.CTM = TranslationMatrix(-box.X, -box.Y).Multiply(m).Multiply(TranslationMatrix(offset, 0)).String()
|
||||||
|
obj.Boundary = editorBoxString(box)
|
||||||
|
return obj, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// editorBoxString 使用统一精度序列化创作边界。
|
||||||
|
// 入参: box 矩形范围
|
||||||
|
// 返回: string 标准Boundary属性值
|
||||||
|
func editorBoxString(box Box) string {
|
||||||
|
return fmt.Sprintf("%s %s %s %s", ofdNumber(box.X), ofdNumber(box.Y), ofdNumber(box.W), ofdNumber(box.H))
|
||||||
|
}
|
||||||
+3
-2
@@ -584,8 +584,9 @@ func (d *fontDir) Stat() (fs.FileInfo, error) {
|
|||||||
return fontFileInfo{name: ".", mode: fs.ModeDir}, nil
|
return fontFileInfo{name: ".", mode: fs.ModeDir}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read 读取字体目录数据
|
// Read 字体目录不提供字节内容,始终返回EOF
|
||||||
// 返回: int 读取字节数, error 错误信息
|
// 入参: []byte 读取缓冲区,不使用
|
||||||
|
// 返回: int 始终为0, error 始终为io.EOF
|
||||||
func (d *fontDir) Read([]byte) (int, error) {
|
func (d *fontDir) Read([]byte) (int, error) {
|
||||||
return 0, io.EOF
|
return 0, io.EOF
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -192,7 +192,9 @@ func fixTrueType(data []byte, fixCmap, fixName bool) (bool, []byte, map[rune]uin
|
|||||||
return true, finalData, mapping, missingCmap, nil
|
return true, finalData, mapping, missingCmap, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasBadPostTable 检查post表是否会被canvas/font拒绝
|
// hasBadPostTable 检查post表的版本、长度及字形数量是否存在已知不兼容
|
||||||
|
// 入参: data post表数据, numGlyphs 字形总数, isTrueType 是否使用TrueType轮廓
|
||||||
|
// 返回: bool 是否需要替换post表
|
||||||
func hasBadPostTable(data []byte, numGlyphs uint16, isTrueType bool) bool {
|
func hasBadPostTable(data []byte, numGlyphs uint16, isTrueType bool) bool {
|
||||||
if len(data) < 32 {
|
if len(data) < 32 {
|
||||||
return true
|
return true
|
||||||
|
|||||||
+3
-1
@@ -537,7 +537,9 @@ func buildCmapTable(numGlyphs uint16, mapping map[rune]uint16) []byte {
|
|||||||
return mainBuf.Bytes()
|
return mainBuf.Bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
// shouldBuildCmapFormat12 判断format 4是否会溢出16位length
|
// shouldBuildCmapFormat12 判断字符超出BMP或format 4长度溢出时是否需要format 12
|
||||||
|
// 入参: mapping 字符到字形的映射
|
||||||
|
// 返回: bool 是否需要构建format 12子表
|
||||||
func shouldBuildCmapFormat12(mapping map[rune]uint16) bool {
|
func shouldBuildCmapFormat12(mapping map[rune]uint16) bool {
|
||||||
if mapping == nil {
|
if mapping == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -96,6 +96,39 @@ func (m Matrix) Transform(x, y float64) (float64, float64) {
|
|||||||
return nx, ny
|
return nx, ny
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TransformBox 获取矩形经仿射变换后的轴对齐边界。
|
||||||
|
// 入参: box 原始矩形范围
|
||||||
|
// 返回: Box 变换后的轴对齐边界
|
||||||
|
func (m Matrix) TransformBox(box Box) Box {
|
||||||
|
x, y := m.Transform(box.X, box.Y)
|
||||||
|
left, top, right, bottom := x, y, x, y
|
||||||
|
for _, point := range [][2]float64{{box.X + box.W, box.Y}, {box.X, box.Y + box.H}, {box.X + box.W, box.Y + box.H}} {
|
||||||
|
x, y := m.Transform(point[0], point[1])
|
||||||
|
left, top, right, bottom = math.Min(left, x), math.Min(top, y), math.Max(right, x), math.Max(bottom, y)
|
||||||
|
}
|
||||||
|
return Box{X: left, Y: top, W: right - left, H: bottom - top}
|
||||||
|
}
|
||||||
|
|
||||||
|
// String 返回标准CTM属性值。
|
||||||
|
// 返回: string 六个空格分隔的矩阵分量
|
||||||
|
func (m Matrix) String() string {
|
||||||
|
return fmt.Sprintf("%s %s %s %s %s %s", ofdNumber(m.a), ofdNumber(m.b), ofdNumber(m.c), ofdNumber(m.d), ofdNumber(m.e), ofdNumber(m.f))
|
||||||
|
}
|
||||||
|
|
||||||
|
// axisAlignedMatrix 判断变换是否保持坐标轴平行,含直角旋转与镜像。
|
||||||
|
// 入参: m 仿射变换矩阵
|
||||||
|
// 返回: bool 是否为非退化的轴对齐变换
|
||||||
|
func axisAlignedMatrix(m Matrix) bool {
|
||||||
|
return m.b == 0 && m.c == 0 && m.a != 0 && m.d != 0 || m.a == 0 && m.d == 0 && m.b != 0 && m.c != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// matrixVector 变换位移,不包含平移分量。
|
||||||
|
// 入参: m 仿射变换矩阵, x、y 原始位移
|
||||||
|
// 返回: float64 变换后的X位移, float64 变换后的Y位移
|
||||||
|
func matrixVector(m Matrix, x, y float64) (float64, float64) {
|
||||||
|
return m.a*x + m.c*y, m.b*x + m.d*y
|
||||||
|
}
|
||||||
|
|
||||||
// Invert 求逆矩阵
|
// Invert 求逆矩阵
|
||||||
// 返回: Matrix 逆矩阵, bool 是否可逆
|
// 返回: Matrix 逆矩阵, bool 是否可逆
|
||||||
func (m Matrix) Invert() (Matrix, bool) {
|
func (m Matrix) Invert() (Matrix, bool) {
|
||||||
|
|||||||
+36
-7
@@ -65,7 +65,7 @@ func NewShape(kind ShapeKind, box Box) (PathObject, error) {
|
|||||||
return object, nil
|
return object, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shape 识别 NewShape 生成的基本路径及其正向轴对齐缩放、平移,返回应用 Boundary 和 CTM 后的几何范围。
|
// Shape 识别 NewShape 生成的基本路径及其轴对齐缩放、直角旋转、镜像和平移。
|
||||||
// 直线范围以起点和有符号的端点位移表示;非基本路径或不支持的变换返回空类型。
|
// 直线范围以起点和有符号的端点位移表示;非基本路径或不支持的变换返回空类型。
|
||||||
// 返回: ShapeKind 图形类型, Box 几何范围(不含描边)
|
// 返回: ShapeKind 图形类型, Box 几何范围(不含描边)
|
||||||
func (p PathObject) Shape() (ShapeKind, Box) {
|
func (p PathObject) Shape() (ShapeKind, Box) {
|
||||||
@@ -110,11 +110,16 @@ func (p PathObject) Shape() (ShapeKind, Box) {
|
|||||||
}
|
}
|
||||||
boundary, err := ParseBox(p.Boundary)
|
boundary, err := ParseBox(p.Boundary)
|
||||||
m := NewMatrix(p.CTM)
|
m := NewMatrix(p.CTM)
|
||||||
if err != nil || m.a <= 0 || m.d <= 0 || m.b != 0 || m.c != 0 {
|
if err != nil || !axisAlignedMatrix(m) {
|
||||||
return "", Box{}
|
return "", Box{}
|
||||||
}
|
}
|
||||||
x, y := m.Transform(box.X, box.Y)
|
if kind == ShapeLine {
|
||||||
box = Box{X: boundary.X + x, Y: boundary.Y + y, W: box.W * m.a, H: box.H * m.d}
|
x, y := m.Transform(box.X, box.Y)
|
||||||
|
w, h := matrixVector(m, box.W, box.H)
|
||||||
|
box = Box{X: boundary.X + x, Y: boundary.Y + y, W: w, H: h}
|
||||||
|
} else {
|
||||||
|
box = TranslationMatrix(boundary.X, boundary.Y).Multiply(m).TransformBox(box)
|
||||||
|
}
|
||||||
if !finite(box.X) || !finite(box.Y) || !finite(box.W) || !finite(box.H) || !finite(box.X+box.W) || !finite(box.Y+box.H) {
|
if !finite(box.X) || !finite(box.Y) || !finite(box.W) || !finite(box.H) || !finite(box.X+box.W) || !finite(box.Y+box.H) {
|
||||||
return "", Box{}
|
return "", Box{}
|
||||||
}
|
}
|
||||||
@@ -141,7 +146,7 @@ func sameShapePath(a, b []string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reshape 调整基本路径在所在坐标系中的几何范围,保留对象标识、变换和绘制属性,不缩放线宽。
|
// Reshape 调整基本路径在所在坐标系中的几何范围,保留对象标识、方向、缩放和绘制属性,不缩放线宽。
|
||||||
// 返回对象可通过 Editor.UpdateObject 写入;仅支持 Shape 可识别的路径。
|
// 返回对象可通过 Editor.UpdateObject 写入;仅支持 Shape 可识别的路径。
|
||||||
// 入参: box 新几何范围,直线使用起点和有符号的端点位移
|
// 入参: box 新几何范围,直线使用起点和有符号的端点位移
|
||||||
// 返回: PathObject 调整后的对象, error 错误信息
|
// 返回: PathObject 调整后的对象, error 错误信息
|
||||||
@@ -157,12 +162,36 @@ func (p PathObject) Reshape(box Box) (PathObject, error) {
|
|||||||
return PathObject{}, fmt.Errorf("shape coordinates must be finite")
|
return PathObject{}, fmt.Errorf("shape coordinates must be finite")
|
||||||
}
|
}
|
||||||
m := NewMatrix(p.CTM)
|
m := NewMatrix(p.CTM)
|
||||||
shape, err := NewShape(kind, Box{W: box.W / m.a, H: box.H / m.d})
|
var w, h float64
|
||||||
|
if m.b == 0 {
|
||||||
|
w, h = box.W/m.a, box.H/m.d
|
||||||
|
} else {
|
||||||
|
w, h = box.H/m.b, box.W/m.c
|
||||||
|
}
|
||||||
|
if kind != ShapeLine {
|
||||||
|
w, h = math.Abs(w), math.Abs(h)
|
||||||
|
}
|
||||||
|
shape, err := NewShape(kind, Box{W: w, H: h})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return PathObject{}, err
|
return PathObject{}, err
|
||||||
}
|
}
|
||||||
boundary, _ := ParseBox(shape.Boundary)
|
boundary, _ := ParseBox(shape.Boundary)
|
||||||
p.Boundary = fmt.Sprintf("%g %g %g %g", box.X+boundary.X*m.a-m.e, box.Y+boundary.Y*m.d-m.f, boundary.W*m.a, boundary.H*m.d)
|
if m.a > 0 && m.d > 0 && m.b == 0 && m.c == 0 {
|
||||||
|
p.Boundary = fmt.Sprintf("%g %g %g %g", box.X+boundary.X*m.a-m.e, box.Y+boundary.Y*m.d-m.f, boundary.W*m.a, boundary.H*m.d)
|
||||||
|
} else {
|
||||||
|
m.e, m.f = 0, 0
|
||||||
|
var x, y float64
|
||||||
|
if kind == ShapeLine {
|
||||||
|
x, y = m.Transform(-boundary.X, -boundary.Y)
|
||||||
|
} else {
|
||||||
|
bounds := m.TransformBox(boundary)
|
||||||
|
x, y = bounds.X, bounds.Y
|
||||||
|
}
|
||||||
|
m = TranslationMatrix(box.X-x, box.Y-y).Multiply(m)
|
||||||
|
bounds := m.TransformBox(Box{W: boundary.W, H: boundary.H})
|
||||||
|
p.Boundary = editorBoxString(bounds)
|
||||||
|
p.CTM = TranslationMatrix(-bounds.X, -bounds.Y).Multiply(m).String()
|
||||||
|
}
|
||||||
p.AbbreviatedData = shape.AbbreviatedData
|
p.AbbreviatedData = shape.AbbreviatedData
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
+29
-2
@@ -437,7 +437,7 @@ func (x *ofdXML) object(object GraphicObject, root bool) {
|
|||||||
attrs.flag("Fill", obj.Fill)
|
attrs.flag("Fill", obj.Fill)
|
||||||
attrs.alpha(obj.Alpha)
|
attrs.alpha(obj.Alpha)
|
||||||
fill, stroke = obj.FillColor, (*FillColor)(obj.StrokeColor)
|
fill, stroke = obj.FillColor, (*FillColor)(obj.StrokeColor)
|
||||||
case "PathObject":
|
case "PathObject", "Path":
|
||||||
obj := object.PathObject
|
obj := object.PathObject
|
||||||
attrs.add("ID", obj.ID)
|
attrs.add("ID", obj.ID)
|
||||||
attrs.add("Boundary", obj.Boundary)
|
attrs.add("Boundary", obj.Boundary)
|
||||||
@@ -467,7 +467,9 @@ func (x *ofdXML) object(object GraphicObject, root bool) {
|
|||||||
attrs.alpha(obj.Alpha)
|
attrs.alpha(obj.Alpha)
|
||||||
}
|
}
|
||||||
x.start(object.Type, attrs)
|
x.start(object.Type, attrs)
|
||||||
if object.Type == "PathObject" {
|
if object.Type == "ImageObject" {
|
||||||
|
x.clips(object.ImageObject.Clips)
|
||||||
|
} else if object.Type == "PathObject" || object.Type == "Path" {
|
||||||
x.color("StrokeColor", stroke)
|
x.color("StrokeColor", stroke)
|
||||||
x.color("FillColor", fill)
|
x.color("FillColor", fill)
|
||||||
x.text("AbbreviatedData", object.PathObject.AbbreviatedData)
|
x.text("AbbreviatedData", object.PathObject.AbbreviatedData)
|
||||||
@@ -486,6 +488,31 @@ func (x *ofdXML) object(object GraphicObject, root bool) {
|
|||||||
x.end(object.Type)
|
x.end(object.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clips 写出图片路径裁剪,保留裁剪集合、区域及路径的变换
|
||||||
|
// 入参: clips 裁剪集合,nil不输出节点
|
||||||
|
func (x *ofdXML) clips(clips *Clips) {
|
||||||
|
if clips == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var attrs ofdAttrs
|
||||||
|
attrs.flag("TransFlag", clips.TransFlag)
|
||||||
|
x.start("Clips", attrs)
|
||||||
|
for _, clip := range clips.Clip {
|
||||||
|
x.start("Clip", nil)
|
||||||
|
for _, area := range clip.Area {
|
||||||
|
var attrs ofdAttrs
|
||||||
|
attrs.add("CTM", area.CTM)
|
||||||
|
x.start("Area", attrs)
|
||||||
|
for _, path := range area.Path {
|
||||||
|
x.object(GraphicObject{Type: "Path", PathObject: path}, false)
|
||||||
|
}
|
||||||
|
x.end("Area")
|
||||||
|
}
|
||||||
|
x.end("Clip")
|
||||||
|
}
|
||||||
|
x.end("Clips")
|
||||||
|
}
|
||||||
|
|
||||||
// textCode 使用XML字符引用转义空格,避免依赖阅读器保留原始空白
|
// textCode 使用XML字符引用转义空格,避免依赖阅读器保留原始空白
|
||||||
// 入参: value OFD文字内容, attrs 文字定位属性
|
// 入参: value OFD文字内容, attrs 文字定位属性
|
||||||
func (x *ofdXML) textCode(value string, attrs ofdAttrs) {
|
func (x *ofdXML) textCode(value string, attrs ofdAttrs) {
|
||||||
|
|||||||
Reference in new issue
Block a user