mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-30 01:27:26 +08:00
This commit is contained in:
1 parent
827f8ab48f
commit
89797af8d6
8 files changed
+307
-85
No files matched your search
+15
-2
@@ -101,9 +101,17 @@
|
|||||||
<option value="horizontal">水平</option>
|
<option value="horizontal">水平</option>
|
||||||
<option value="vertical">垂直</option>
|
<option value="vertical">垂直</option>
|
||||||
</select>
|
</select>
|
||||||
|
<span>变换</span>
|
||||||
|
</div>
|
||||||
|
<div class="editor-group editor-image">
|
||||||
|
<select id="imageFit" aria-label="图片适应方式" disabled>
|
||||||
|
<option value="" hidden>缩放</option>
|
||||||
|
<option value="contain">适应</option>
|
||||||
|
<option value="cover">填充</option>
|
||||||
|
</select>
|
||||||
<button id="cropImageButton" class="button" type="button" aria-label="裁剪图片" aria-pressed="false" disabled>裁剪</button>
|
<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>
|
<button id="resetCropButton" class="button" type="button" aria-label="还原图片" disabled>还原</button>
|
||||||
<span>变换</span>
|
<span>图片</span>
|
||||||
</div>
|
</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>
|
||||||
@@ -118,8 +126,9 @@
|
|||||||
<option value="right">右齐</option>
|
<option value="right">右齐</option>
|
||||||
<option value="justify">两端</option>
|
<option value="justify">两端</option>
|
||||||
</select>
|
</select>
|
||||||
<button id="textWrap" class="button" type="button" aria-label="自动折行" aria-pressed="false" disabled>折行</button>
|
|
||||||
<label class="editor-field"><span>行距</span><span class="editor-number"><input id="textLineHeight" type="number" min="0" step="any" placeholder="自动" aria-label="行距" title="行距,0为自动" disabled><span>pt</span></span></label>
|
<label class="editor-field"><span>行距</span><span class="editor-number"><input id="textLineHeight" type="number" min="0" step="any" placeholder="自动" aria-label="行距" title="行距,0为自动" disabled><span>pt</span></span></label>
|
||||||
|
<button id="textWrap" class="button" type="button" aria-label="自动折行" aria-pressed="false" disabled>折行</button>
|
||||||
|
<label class="editor-field"><span>字距</span><span class="editor-number"><input id="textSpacing" type="number" step="any" placeholder="0" aria-label="字距" title="字距" disabled><span>pt</span></span></label>
|
||||||
<span>段落</span>
|
<span>段落</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-group">
|
<div class="editor-group">
|
||||||
@@ -201,6 +210,10 @@
|
|||||||
<label for="insertImage">图片</label>
|
<label for="insertImage">图片</label>
|
||||||
<input id="insertImage" type="file" accept=".png,.jpg,.jpeg,image/png,image/jpeg" required autofocus>
|
<input id="insertImage" type="file" accept=".png,.jpg,.jpeg,image/png,image/jpeg" required autofocus>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="insertFitRow" class="form-row" hidden>
|
||||||
|
<label for="insertFit">缩放</label>
|
||||||
|
<select id="insertFit"><option value="contain">适应</option><option value="cover">填充</option></select>
|
||||||
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<div id="insertStatus" class="form-status" role="status"></div>
|
<div id="insertStatus" class="form-status" role="status"></div>
|
||||||
<button id="insertCancel" class="button" type="button">取消</button>
|
<button id="insertCancel" class="button" type="button">取消</button>
|
||||||
|
|||||||
+23
-3
@@ -113,12 +113,24 @@ a:focus-visible {
|
|||||||
|
|
||||||
.editor-file,
|
.editor-file,
|
||||||
.editor-paragraph,
|
.editor-paragraph,
|
||||||
.editor-transform,
|
.editor-image,
|
||||||
.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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-transform {
|
||||||
|
grid-template-columns: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-image > select {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-paragraph > .editor-field {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-objects {
|
.editor-objects {
|
||||||
grid-template-columns: repeat(4, max-content);
|
grid-template-columns: repeat(4, max-content);
|
||||||
}
|
}
|
||||||
@@ -178,8 +190,8 @@ a:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor-paragraph {
|
.editor-paragraph {
|
||||||
width: 172px;
|
width: 218px;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: 54px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-file #saveButton {
|
.editor-file #saveButton {
|
||||||
@@ -196,6 +208,14 @@ a:focus-visible {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#textColor {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape-paint {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
.shape-paint,
|
.shape-paint,
|
||||||
.shape-paint label {
|
.shape-paint label {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+67
-20
@@ -39,7 +39,7 @@ const state = {
|
|||||||
insertObject: null,
|
insertObject: null,
|
||||||
textFonts: [],
|
textFonts: [],
|
||||||
textFontID: null,
|
textFontID: null,
|
||||||
textDefaults: { type: "TextObject", size: 12 * 25.4 / 72, color: "#000000", wrap: true, align: "left", paragraphHeight: 0 },
|
textDefaults: { type: "TextObject", size: 12 * 25.4 / 72, color: "#000000", wrap: true, align: "left", paragraphHeight: 0, letterSpacing: 0 },
|
||||||
ofdBytes: null,
|
ofdBytes: null,
|
||||||
fileName: "ofdgo.ofd",
|
fileName: "ofdgo.ofd",
|
||||||
openSeq: 0,
|
openSeq: 0,
|
||||||
@@ -95,12 +95,14 @@ const el = {
|
|||||||
objectDistribute: document.querySelector("#objectDistribute"),
|
objectDistribute: document.querySelector("#objectDistribute"),
|
||||||
objectRotate: document.querySelector("#objectRotate"),
|
objectRotate: document.querySelector("#objectRotate"),
|
||||||
objectFlip: document.querySelector("#objectFlip"),
|
objectFlip: document.querySelector("#objectFlip"),
|
||||||
|
imageFit: document.querySelector("#imageFit"),
|
||||||
cropImageButton: document.querySelector("#cropImageButton"),
|
cropImageButton: document.querySelector("#cropImageButton"),
|
||||||
resetCropButton: document.querySelector("#resetCropButton"),
|
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"),
|
||||||
textLineHeight: document.querySelector("#textLineHeight"),
|
textLineHeight: document.querySelector("#textLineHeight"),
|
||||||
|
textSpacing: document.querySelector("#textSpacing"),
|
||||||
textFont: document.querySelector("#textFont"),
|
textFont: document.querySelector("#textFont"),
|
||||||
textFontAdd: document.querySelector("#textFontAdd"),
|
textFontAdd: document.querySelector("#textFontAdd"),
|
||||||
textSize: document.querySelector("#textSize"),
|
textSize: document.querySelector("#textSize"),
|
||||||
@@ -142,6 +144,8 @@ const el = {
|
|||||||
insertPanel: document.querySelector("#insertPanel"),
|
insertPanel: document.querySelector("#insertPanel"),
|
||||||
insertForm: document.querySelector("#insertForm"),
|
insertForm: document.querySelector("#insertForm"),
|
||||||
insertImage: document.querySelector("#insertImage"),
|
insertImage: document.querySelector("#insertImage"),
|
||||||
|
insertFitRow: document.querySelector("#insertFitRow"),
|
||||||
|
insertFit: document.querySelector("#insertFit"),
|
||||||
insertStatus: document.querySelector("#insertStatus"),
|
insertStatus: document.querySelector("#insertStatus"),
|
||||||
insertCancel: document.querySelector("#insertCancel"),
|
insertCancel: document.querySelector("#insertCancel"),
|
||||||
insertSubmit: document.querySelector("#insertSubmit"),
|
insertSubmit: document.querySelector("#insertSubmit"),
|
||||||
@@ -244,7 +248,7 @@ 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 - item.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, item.letterSpacing || 0),
|
||||||
onCrop: (item, box) => changeDocument("ofdgoCropImage", item, box.x, box.y, box.width, box.height),
|
onCrop: (item, box) => changeDocument("ofdgoCropImage", item, box.x, box.y, box.width, box.height),
|
||||||
onCropChange: () => {
|
onCropChange: () => {
|
||||||
updatePendingChanges();
|
updatePendingChanges();
|
||||||
@@ -259,7 +263,7 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
|
|||||||
box.x, box.y, box.width, box.height, style.fill, style.fillColor, style.stroke, style.strokeColor, style.lineWidth),
|
box.x, box.y, box.width, box.height, style.fill, style.fillColor, style.stroke, style.strokeColor, style.lineWidth),
|
||||||
onDrawText: beginCanvasText,
|
onDrawText: beginCanvasText,
|
||||||
onCommitText: (item, value, fontData) => item.draft
|
onCommitText: (item, value, fontData) => item.draft
|
||||||
? changeDocument("ofdgoInsertText", null, item.index, value, fontData || item.fontData, item.x, item.y, item.size, item.color, item.width, item.wrap, item.align, item.paragraphHeight)
|
? changeDocument("ofdgoInsertText", null, item.index, value, fontData || item.fontData, item.x, item.y, item.size, item.color, item.width, item.wrap, item.align, item.paragraphHeight, item.letterSpacing)
|
||||||
: 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;
|
||||||
@@ -271,7 +275,6 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// updatePendingChanges 将未提交的文字和裁剪计入修改状态,不生成历史记录。
|
|
||||||
function updatePendingChanges() {
|
function updatePendingChanges() {
|
||||||
const editing = canvasEditor.input;
|
const editing = canvasEditor.input;
|
||||||
setDirty(state.editing && (state.editorInfo?.revision !== state.savedRevision
|
setDirty(state.editing && (state.editorInfo?.revision !== state.savedRevision
|
||||||
@@ -282,7 +285,6 @@ el.editorTools.addEventListener("pointerdown", (event) => {
|
|||||||
if (canvasEditor.input && event.target.closest("button")) event.preventDefault();
|
if (canvasEditor.input && event.target.closest("button")) event.preventDefault();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -323,9 +325,10 @@ el.textColor.addEventListener("change", () => changeTextStyle(true));
|
|||||||
editorClick(el.textWrap, () => changeParagraph(!currentTextStyle().wrap));
|
editorClick(el.textWrap, () => changeParagraph(!currentTextStyle().wrap));
|
||||||
el.textAlign.addEventListener("change", () => changeParagraph());
|
el.textAlign.addEventListener("change", () => changeParagraph());
|
||||||
el.textLineHeight.addEventListener("change", () => changeParagraph());
|
el.textLineHeight.addEventListener("change", () => changeParagraph());
|
||||||
|
el.textSpacing.addEventListener("change", () => changeParagraph());
|
||||||
el.textFont.addEventListener("change", changeTextFont);
|
el.textFont.addEventListener("change", changeTextFont);
|
||||||
el.shapeWidth.addEventListener("focus", () => { el.shapeWidth.defaultValue = el.shapeWidth.value; });
|
el.shapeWidth.addEventListener("focus", () => { el.shapeWidth.defaultValue = el.shapeWidth.value; });
|
||||||
for (const input of [el.textSize, el.shapeWidth, el.textLineHeight]) {
|
for (const input of [el.textSize, el.shapeWidth, el.textLineHeight, el.textSpacing]) {
|
||||||
input.addEventListener("keydown", (event) => {
|
input.addEventListener("keydown", (event) => {
|
||||||
if (event.isComposing) return;
|
if (event.isComposing) return;
|
||||||
if (event.key === "Enter" || event.key === "Escape") {
|
if (event.key === "Enter" || event.key === "Escape") {
|
||||||
@@ -387,7 +390,12 @@ el.cropImageButton.addEventListener("click", () => {
|
|||||||
if (document.body.hasAttribute("aria-busy")) return;
|
if (document.body.hasAttribute("aria-busy")) return;
|
||||||
if (canvasEditor.crop) return canvasEditor.commitCrop();
|
if (canvasEditor.crop) return canvasEditor.commitCrop();
|
||||||
const item = canvasEditor.selected;
|
const item = canvasEditor.selected;
|
||||||
if (item?.type === "ImageObject") canvasEditor.startCrop(item);
|
if (item?.type === "ImageObject") return startImageCrop(item);
|
||||||
|
});
|
||||||
|
el.imageFit.addEventListener("change", () => {
|
||||||
|
const mode = el.imageFit.value, item = canvasEditor.selected;
|
||||||
|
el.imageFit.value = "";
|
||||||
|
if (mode && item?.type === "ImageObject" && !canvasEditor.crop) return changeDocument("ofdgoFitImage", item, mode);
|
||||||
});
|
});
|
||||||
el.resetCropButton.addEventListener("click", () => {
|
el.resetCropButton.addEventListener("click", () => {
|
||||||
if (document.body.hasAttribute("aria-busy")) return;
|
if (document.body.hasAttribute("aria-busy")) return;
|
||||||
@@ -788,12 +796,46 @@ async function editCanvasObject(item) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startImageCrop(item) {
|
||||||
|
const openSeq = state.openSeq;
|
||||||
|
const urls = [];
|
||||||
|
let mounted = false;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const page = await callWASM("ofdgoPreviewImage", item.index, item.id);
|
||||||
|
if (openSeq !== state.openSeq || !item.surface.isConnected) return;
|
||||||
|
const images = new Map(state.svgImages);
|
||||||
|
for (const image of page.images) {
|
||||||
|
if (images.has(image.name)) continue;
|
||||||
|
const url = URL.createObjectURL(new Blob([image.bytes], { type: image.mime }));
|
||||||
|
urls.push(url);
|
||||||
|
images.set(image.name, { url });
|
||||||
|
}
|
||||||
|
await Promise.all(urls.map(url => {
|
||||||
|
const image = new Image();
|
||||||
|
image.src = url;
|
||||||
|
return image.decode();
|
||||||
|
}));
|
||||||
|
if (openSeq !== state.openSeq || canvasEditor.selected !== item) return;
|
||||||
|
const svg = parseSVG(page.svg, `crop${openSeq}-${item.id}`, images);
|
||||||
|
svg.classList.add("ofd-svg");
|
||||||
|
canvasEditor.startCrop(item, { svg, urls });
|
||||||
|
mounted = true;
|
||||||
|
} catch (err) {
|
||||||
|
if (openSeq === state.openSeq) showError(err, false);
|
||||||
|
} finally {
|
||||||
|
if (!mounted) urls.forEach(url => URL.revokeObjectURL(url));
|
||||||
|
if (openSeq === state.openSeq) setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openInsertPanel(item = null) {
|
function openInsertPanel(item = null) {
|
||||||
if (!state.editing || document.body.hasAttribute("aria-busy")) {
|
if (!state.editing || document.body.hasAttribute("aria-busy")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
el.insertForm.reset();
|
el.insertForm.reset();
|
||||||
state.insertObject = item;
|
state.insertObject = item;
|
||||||
|
el.insertFitRow.hidden = !item;
|
||||||
el.insertPanel.setAttribute("aria-label", item ? "替换图片" : "添加图片");
|
el.insertPanel.setAttribute("aria-label", item ? "替换图片" : "添加图片");
|
||||||
el.insertSubmit.textContent = item ? "替换" : "添加";
|
el.insertSubmit.textContent = item ? "替换" : "添加";
|
||||||
el.insertStatus.textContent = "";
|
el.insertStatus.textContent = "";
|
||||||
@@ -801,12 +843,10 @@ function openInsertPanel(item = null) {
|
|||||||
el.insertImage.focus();
|
el.insertImage.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// currentTextStyle 获取所选文字样式,未选中文字时使用新文字的默认样式。
|
|
||||||
function currentTextStyle() {
|
function currentTextStyle() {
|
||||||
return canvasEditor.selected?.type === "TextObject" ? canvasEditor.selected : state.textDefaults;
|
return canvasEditor.selected?.type === "TextObject" ? canvasEditor.selected : state.textDefaults;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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") {
|
||||||
@@ -821,7 +861,7 @@ async function toggleTextTool() {
|
|||||||
if (openSeq !== state.openSeq) return;
|
if (openSeq !== state.openSeq) return;
|
||||||
const style = currentTextStyle();
|
const style = currentTextStyle();
|
||||||
state.textDefaults = { type: "TextObject", size: style.size, color: style.color, wrap: style.wrap,
|
state.textDefaults = { type: "TextObject", size: style.size, color: style.color, wrap: style.wrap,
|
||||||
align: style.align || "left", paragraphHeight: style.paragraphHeight || 0,
|
align: style.align || "left", paragraphHeight: style.paragraphHeight || 0, letterSpacing: style.letterSpacing || 0,
|
||||||
fontChoice: state.textFonts[Number(el.textFont.value)] || state.textDefaults.fontChoice };
|
fontChoice: state.textFonts[Number(el.textFont.value)] || state.textDefaults.fontChoice };
|
||||||
state.selectObjects = true;
|
state.selectObjects = true;
|
||||||
setPan(false);
|
setPan(false);
|
||||||
@@ -829,7 +869,6 @@ async function toggleTextTool() {
|
|||||||
el.viewerPanel.focus({ preventScroll: true });
|
el.viewerPanel.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
||||||
@@ -889,7 +928,6 @@ function updateTextFonts(item, refresh = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)];
|
||||||
@@ -943,7 +981,7 @@ async function insertObject(event) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const doc = item
|
const doc = item
|
||||||
? await callWASM("ofdgoReplaceImage", item.index, item.id, data)
|
? await callWASM("ofdgoReplaceImage", item.index, item.id, data, el.insertFit.value)
|
||||||
: await callWASM("ofdgoInsertImage", index, data, x, y, page.width - x * 2);
|
: await callWASM("ofdgoInsertImage", index, data, x, y, page.width - x * 2);
|
||||||
if (openSeq !== state.openSeq) {
|
if (openSeq !== state.openSeq) {
|
||||||
return;
|
return;
|
||||||
@@ -981,7 +1019,6 @@ function textPoints(size) {
|
|||||||
return Number((size * 72 / 25.4).toFixed(2));
|
return Number((size * 72 / 25.4).toFixed(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -991,12 +1028,18 @@ async function changeParagraph(wrap = currentTextStyle().wrap) {
|
|||||||
}
|
}
|
||||||
const points = Number(el.textLineHeight.value);
|
const points = Number(el.textLineHeight.value);
|
||||||
const height = points === textPoints(item.paragraphHeight || 0) ? item.paragraphHeight || 0 : points * 25.4 / 72;
|
const height = points === textPoints(item.paragraphHeight || 0) ? item.paragraphHeight || 0 : points * 25.4 / 72;
|
||||||
if (item === state.textDefaults) {
|
if (!el.textSpacing.checkValidity()) {
|
||||||
Object.assign(item, { wrap: Boolean(wrap), align: el.textAlign.value, paragraphHeight: height });
|
|
||||||
updateObjectControls(canvasEditor.selected, true);
|
updateObjectControls(canvasEditor.selected, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await changeDocument("ofdgoLayoutText", item, null, null, Boolean(wrap), el.textAlign.value, height);
|
const spacingPoints = Number(el.textSpacing.value);
|
||||||
|
const spacing = spacingPoints === textPoints(item.letterSpacing || 0) ? item.letterSpacing || 0 : spacingPoints * 25.4 / 72;
|
||||||
|
if (item === state.textDefaults) {
|
||||||
|
Object.assign(item, { wrap: Boolean(wrap), align: el.textAlign.value, paragraphHeight: height, letterSpacing: spacing });
|
||||||
|
updateObjectControls(canvasEditor.selected, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await changeDocument("ofdgoLayoutText", item, null, null, Boolean(wrap), el.textAlign.value, height, spacing);
|
||||||
updateObjectControls(canvasEditor.selected, true);
|
updateObjectControls(canvasEditor.selected, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2984,9 +3027,9 @@ function layoutPageShell(shell, page) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSVG(svgText, prefix = "") {
|
function parseSVG(svgText, prefix = "", images = state.svgImages) {
|
||||||
const template = document.createElement("template");
|
const template = document.createElement("template");
|
||||||
template.innerHTML = svgText.replace(/xlink:href="(ofdgo-image-[a-f0-9]{64})"/g, (_, name) => `xlink:href="${state.svgImages.get(name).url}"`);
|
template.innerHTML = svgText.replace(/xlink:href="(ofdgo-image-[a-f0-9]{64})"/g, (_, name) => `xlink:href="${images.get(name).url}"`);
|
||||||
const svg = document.adoptNode(template.content.firstElementChild);
|
const svg = document.adoptNode(template.content.firstElementChild);
|
||||||
prefixSVGIds(svg, prefix);
|
prefixSVGIds(svg, prefix);
|
||||||
return svg;
|
return svg;
|
||||||
@@ -4206,6 +4249,7 @@ function updateObjectControls(item, reset = false) {
|
|||||||
el.objectAlign.disabled = disabled || cropping;
|
el.objectAlign.disabled = disabled || cropping;
|
||||||
el.objectRotate.disabled = el.objectFlip.disabled = disabled || cropping;
|
el.objectRotate.disabled = el.objectFlip.disabled = disabled || cropping;
|
||||||
el.cropImageButton.disabled = disabled || item.type !== "ImageObject";
|
el.cropImageButton.disabled = disabled || item.type !== "ImageObject";
|
||||||
|
el.imageFit.disabled = el.cropImageButton.disabled || cropping;
|
||||||
el.cropImageButton.textContent = cropping ? "完成" : "裁剪";
|
el.cropImageButton.textContent = cropping ? "完成" : "裁剪";
|
||||||
el.cropImageButton.setAttribute("aria-label", cropping ? "完成裁剪" : "裁剪图片");
|
el.cropImageButton.setAttribute("aria-label", cropping ? "完成裁剪" : "裁剪图片");
|
||||||
el.cropImageButton.setAttribute("aria-pressed", String(cropping));
|
el.cropImageButton.setAttribute("aria-pressed", String(cropping));
|
||||||
@@ -4219,12 +4263,15 @@ function updateObjectControls(item, reset = false) {
|
|||||||
el.textFont.disabled = !state.editing || !state.ready || state.exporting || Boolean(item?.items);
|
el.textFont.disabled = !state.editing || !state.ready || state.exporting || Boolean(item?.items);
|
||||||
el.textSize.disabled = el.textColor.disabled = el.textFont.disabled || Boolean(item?.draft);
|
el.textSize.disabled = el.textColor.disabled = el.textFont.disabled || Boolean(item?.draft);
|
||||||
el.textFontAdd.disabled = !state.editing || !state.ready || state.exporting;
|
el.textFontAdd.disabled = !state.editing || !state.ready || state.exporting;
|
||||||
el.textAlign.disabled = el.textWrap.disabled = el.textLineHeight.disabled = el.textSize.disabled;
|
el.textAlign.disabled = el.textWrap.disabled = el.textLineHeight.disabled = el.textSpacing.disabled = el.textSize.disabled;
|
||||||
el.textAlign.value = text.align || "left";
|
el.textAlign.value = text.align || "left";
|
||||||
el.textWrap.setAttribute("aria-pressed", String(Boolean(text.wrap)));
|
el.textWrap.setAttribute("aria-pressed", String(Boolean(text.wrap)));
|
||||||
if (reset || document.activeElement !== el.textLineHeight) {
|
if (reset || document.activeElement !== el.textLineHeight) {
|
||||||
el.textLineHeight.value = text.paragraphHeight ? String(textPoints(text.paragraphHeight)) : "";
|
el.textLineHeight.value = text.paragraphHeight ? String(textPoints(text.paragraphHeight)) : "";
|
||||||
}
|
}
|
||||||
|
if (reset || document.activeElement !== el.textSpacing) {
|
||||||
|
el.textSpacing.value = text.letterSpacing ? String(textPoints(text.letterSpacing)) : "";
|
||||||
|
}
|
||||||
updateTextFonts(item);
|
updateTextFonts(item);
|
||||||
if (reset || el.textSize.disabled || document.activeElement !== el.textSize) {
|
if (reset || el.textSize.disabled || document.activeElement !== el.textSize) {
|
||||||
el.textSize.value = document.activeElement === el.textSize ? textPoints(text.size) : Math.round(textPoints(text.size));
|
el.textSize.value = document.activeElement === el.textSize ? textPoints(text.size) : Math.round(textPoints(text.size));
|
||||||
|
|||||||
+19
-32
@@ -21,14 +21,12 @@ 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 计算同页对象的联合边界,不改变对象位置。
|
|
||||||
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 查找最近的边缘或中心辅助线,不改变对象坐标。
|
|
||||||
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 +49,6 @@ function alignmentGuides(box, targets, tolerance) {
|
|||||||
return lines.join("");
|
return lines.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
// transformedBox 将本地矩形映射到页面,用于变换后的文字框预览。
|
|
||||||
function transformedBox(box, matrix) {
|
function transformedBox(box, matrix) {
|
||||||
const [a, b, c, d, e, f] = 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]]
|
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]]
|
||||||
@@ -60,7 +57,6 @@ function transformedBox(box, matrix) {
|
|||||||
return { x, y, width: Math.max(...points.map(p => p[0])) - x, height: Math.max(...points.map(p => p[1])) - y };
|
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) {
|
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)),
|
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)) };
|
y: Math.max(bounds.y, Math.min(box.y + dy, bounds.y + bounds.height - box.height)) };
|
||||||
@@ -70,7 +66,6 @@ function cropBox(box, bounds, dx, dy, corner) {
|
|||||||
height: Math.min(bounds.y + bounds.height, next.y + next.height) - y };
|
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") {
|
||||||
@@ -87,7 +82,6 @@ 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 移动线段端点或调整基本图形范围,不缩放描边。
|
|
||||||
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 };
|
||||||
@@ -112,7 +106,6 @@ 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 使用统一的页面坐标绘制图形及端点调整预览。
|
|
||||||
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 }
|
||||||
@@ -172,7 +165,7 @@ export class CanvasEditor {
|
|||||||
|
|
||||||
mount(index, page, surface) {
|
mount(index, page, surface) {
|
||||||
const crop = this.crop?.item.index === index ? this.crop : null;
|
const crop = this.crop?.item.index === index ? this.crop : null;
|
||||||
if (crop) this.closeCrop();
|
if (crop) this.closeCrop(true);
|
||||||
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);
|
||||||
@@ -212,9 +205,11 @@ export class CanvasEditor {
|
|||||||
if (pending) this.pendingSelection = null;
|
if (pending) this.pendingSelection = null;
|
||||||
if (crop && selection.length === 1 && selection[0].id === crop.item.id
|
if (crop && selection.length === 1 && selection[0].id === crop.item.id
|
||||||
&& ["x", "y", "width", "height"].every(key => selection[0][key] === crop.item[key])) {
|
&& ["x", "y", "width", "height"].every(key => selection[0][key] === crop.item[key])) {
|
||||||
this.startCrop(selection[0]);
|
this.startCrop(selection[0], crop.pagePreview);
|
||||||
this.crop.box = { ...crop.box };
|
this.crop.box = { ...crop.box };
|
||||||
this.paintCrop();
|
this.paintCrop();
|
||||||
|
} else if (crop) {
|
||||||
|
crop.pagePreview.urls.forEach(url => URL.revokeObjectURL(url));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +242,6 @@ export class CanvasEditor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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, {
|
||||||
@@ -263,12 +257,10 @@ export class CanvasEditor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// items 获取选中的真实对象,临时多选框不作为文档对象。
|
|
||||||
items() {
|
items() {
|
||||||
return this.selected?.items || (this.selected ? [this.selected] : []);
|
return this.selected?.items || (this.selected ? [this.selected] : []);
|
||||||
}
|
}
|
||||||
|
|
||||||
// setSelection 更新同页对象选区,并同步工具栏。
|
|
||||||
setSelection(items) {
|
setSelection(items) {
|
||||||
if (this.crop && (items.length !== 1 || items[0] !== this.crop.item)) this.closeCrop();
|
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();
|
||||||
@@ -391,7 +383,7 @@ export class CanvasEditor {
|
|||||||
if (drag.crop) {
|
if (drag.crop) {
|
||||||
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);
|
||||||
this.crop.box = cropBox(drag.box, this.crop.item, to.x - from.x, to.y - from.y, drag.corner);
|
this.crop.box = cropBox(drag.box, this.crop.item.imageBounds, to.x - from.x, to.y - from.y, drag.corner);
|
||||||
this.paintCrop();
|
this.paintCrop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -434,7 +426,6 @@ export class CanvasEditor {
|
|||||||
if (!drag.corner) this.showGuides(drag);
|
if (!drag.corner) this.showGuides(drag);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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, {
|
||||||
@@ -528,7 +519,6 @@ export class CanvasEditor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
||||||
@@ -546,7 +536,6 @@ export class CanvasEditor {
|
|||||||
this.viewer.setPointerCapture(event.pointerId);
|
this.viewer.setPointerCapture(event.pointerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
||||||
@@ -583,7 +572,6 @@ export class CanvasEditor {
|
|||||||
this.viewer.setPointerCapture(event.pointerId);
|
this.viewer.setPointerCapture(event.pointerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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")) {
|
||||||
@@ -599,7 +587,6 @@ export class CanvasEditor {
|
|||||||
return nearest;
|
return nearest;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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");
|
||||||
@@ -631,7 +618,6 @@ 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 在指针静止时响应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) {
|
||||||
@@ -707,6 +693,7 @@ export class CanvasEditor {
|
|||||||
fontFamily: `"${face.family}"`, fontSize: `${item.size * PX_PER_MM}px`,
|
fontFamily: `"${face.family}"`, fontSize: `${item.size * PX_PER_MM}px`,
|
||||||
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",
|
||||||
|
letterSpacing: `${(item.letterSpacing || 0) * PX_PER_MM}px`,
|
||||||
});
|
});
|
||||||
if (item.textFrame) {
|
if (item.textFrame) {
|
||||||
const { width, height, matrix: [a, b, c, d, e, f] } = item.textFrame;
|
const { width, height, matrix: [a, b, c, d, e, f] } = item.textFrame;
|
||||||
@@ -741,14 +728,12 @@ export class CanvasEditor {
|
|||||||
if (!item.draft) input.select();
|
if (!item.draft) input.select();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 暂存输入框字体,后续与文字内容一起提交。
|
|
||||||
setTextFont(face, data) {
|
setTextFont(face, data) {
|
||||||
const editing = this.input;
|
const editing = this.input;
|
||||||
document.fonts.add(face);
|
document.fonts.add(face);
|
||||||
@@ -794,12 +779,16 @@ export class CanvasEditor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startCrop 开始调整图片保留区域,确认前不修改文档。
|
startCrop(item, pagePreview) {
|
||||||
startCrop(item) {
|
|
||||||
this.cancel();
|
this.cancel();
|
||||||
this.closeCrop();
|
this.closeCrop();
|
||||||
this.setTool("");
|
this.setTool("");
|
||||||
const box = { x: item.x, y: item.y, width: item.width, height: item.height };
|
const bounds = item.imageBounds;
|
||||||
|
const x = Math.max(item.x, bounds.x), y = Math.max(item.y, bounds.y);
|
||||||
|
const box = { x, y, width: Math.min(item.x + item.width, bounds.x + bounds.width) - x,
|
||||||
|
height: Math.min(item.y + item.height, bounds.y + bounds.height) - y };
|
||||||
|
const original = item.surface.querySelector(".ofd-svg");
|
||||||
|
original.replaceWith(pagePreview.svg);
|
||||||
const { preview, node: mask } = this.createPreview(item.surface, item.page, "path", { fill: true, fillColor: "rgba(255,255,255,0.72)" });
|
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");
|
mask.setAttribute("fill-rule", "evenodd");
|
||||||
const node = document.createElement("div");
|
const node = document.createElement("div");
|
||||||
@@ -812,26 +801,23 @@ export class CanvasEditor {
|
|||||||
}
|
}
|
||||||
item.surface.append(node);
|
item.surface.append(node);
|
||||||
item.surface.classList.add("cropping");
|
item.surface.classList.add("cropping");
|
||||||
this.crop = { item, box, node, mask, preview };
|
this.crop = { item, box, initialBox: { ...box }, node, mask, preview, original, pagePreview };
|
||||||
this.paintCrop();
|
this.paintCrop();
|
||||||
this.viewer.focus({ preventScroll: true });
|
this.viewer.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// paintCrop 更新遮罩及手柄,不重新渲染或复制图片数据。
|
|
||||||
paintCrop() {
|
paintCrop() {
|
||||||
const { item, box, node, mask } = this.crop;
|
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`;
|
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));
|
mask.setAttribute("d", rectangle(item.imageBounds) + 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` });
|
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();
|
this.options.onCropChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
// cropChanged 判断裁剪范围是否有待提交的变更。
|
|
||||||
cropChanged() {
|
cropChanged() {
|
||||||
return Boolean(this.crop && ["x", "y", "width", "height"].some(key => this.crop.box[key] !== this.crop.item[key]));
|
return Boolean(this.crop && ["x", "y", "width", "height"].some(key => this.crop.box[key] !== this.crop.initialBox[key]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// commitCrop 将裁剪作为一次可撤销操作提交,失败时保留预览。
|
|
||||||
async commitCrop() {
|
async commitCrop() {
|
||||||
if (!this.crop) return true;
|
if (!this.crop) return true;
|
||||||
if (this.options.busy()) return false;
|
if (this.options.busy()) return false;
|
||||||
@@ -845,14 +831,15 @@ export class CanvasEditor {
|
|||||||
return Boolean(saved);
|
return Boolean(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
// closeCrop 移除临时裁剪区域,不修改文档内容。
|
closeCrop(keepPreview = false) {
|
||||||
closeCrop() {
|
|
||||||
if (!this.crop) return;
|
if (!this.crop) return;
|
||||||
if (this.drag?.crop) this.cancel();
|
if (this.drag?.crop) this.cancel();
|
||||||
const crop = this.crop;
|
const crop = this.crop;
|
||||||
this.crop = null;
|
this.crop = null;
|
||||||
crop.node.remove();
|
crop.node.remove();
|
||||||
crop.preview.remove();
|
crop.preview.remove();
|
||||||
|
crop.pagePreview.svg.replaceWith(crop.original);
|
||||||
|
if (!keepPreview) crop.pagePreview.urls.forEach(url => URL.revokeObjectURL(url));
|
||||||
crop.item.surface.classList.remove("cropping");
|
crop.item.surface.classList.remove("cropping");
|
||||||
this.options.onCropChange();
|
this.options.onCropChange();
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-11
@@ -119,6 +119,8 @@ func RunWASM() {
|
|||||||
registerCallback("ofdgoRotateObjects", rotateObjects)
|
registerCallback("ofdgoRotateObjects", rotateObjects)
|
||||||
registerCallback("ofdgoFlipObjects", flipObjects)
|
registerCallback("ofdgoFlipObjects", flipObjects)
|
||||||
registerCallback("ofdgoCropImage", cropImage)
|
registerCallback("ofdgoCropImage", cropImage)
|
||||||
|
registerCallback("ofdgoFitImage", fitImage)
|
||||||
|
registerCallback("ofdgoPreviewImage", previewImage)
|
||||||
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) })
|
||||||
@@ -318,10 +320,6 @@ func renderPage(args []js.Value) (any, error) {
|
|||||||
for i, font := range page.Fonts {
|
for i, font := range page.Fonts {
|
||||||
fonts[i] = map[string]any{"name": font.Name, "weight": font.Weight, "style": font.Style}
|
fonts[i] = map[string]any{"name": font.Name, "weight": font.Weight, "style": font.Style}
|
||||||
}
|
}
|
||||||
images := make([]any, len(page.Images))
|
|
||||||
for i, image := range page.Images {
|
|
||||||
images[i] = map[string]any{"name": image.Name, "mime": image.MIME, "bytes": bytesToJS(image.Data)}
|
|
||||||
}
|
|
||||||
return successResult(map[string]any{
|
return successResult(map[string]any{
|
||||||
"index": page.Index,
|
"index": page.Index,
|
||||||
"number": page.Number,
|
"number": page.Number,
|
||||||
@@ -331,7 +329,7 @@ func renderPage(args []js.Value) (any, error) {
|
|||||||
"svg": page.SVG,
|
"svg": page.SVG,
|
||||||
"links": links,
|
"links": links,
|
||||||
"fonts": fonts,
|
"fonts": fonts,
|
||||||
"images": images,
|
"images": svgImagesToJS(page.Images),
|
||||||
"text": string(textData),
|
"text": string(textData),
|
||||||
"objects": objects,
|
"objects": objects,
|
||||||
}), nil
|
}), nil
|
||||||
@@ -662,6 +660,7 @@ 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
|
||||||
|
item["letterSpacing"] = layout.LetterSpacing
|
||||||
frame, err := object.TextObject.TextFrame()
|
frame, err := object.TextObject.TextFrame()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -724,7 +723,7 @@ func editorFont(args []js.Value) (any, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// replaceImage 替换图片资源并更新预览
|
// replaceImage 替换图片资源并更新预览
|
||||||
// 入参: args 页码、对象标识和图片数据
|
// 入参: args 页码、对象标识、图片数据和适应方式
|
||||||
// 返回: any 文档信息, error 错误信息
|
// 返回: any 文档信息, error 错误信息
|
||||||
func replaceImage(args []js.Value) (any, error) {
|
func replaceImage(args []js.Value) (any, error) {
|
||||||
if currentEditor == nil {
|
if currentEditor == nil {
|
||||||
@@ -735,7 +734,7 @@ func replaceImage(args []js.Value) (any, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
revision := currentEditor.Revision()
|
revision := currentEditor.Revision()
|
||||||
if err := currentEditor.ReplaceImage(args[0].Int(), args[1].String(), data); err != nil {
|
if err := currentEditor.ReplaceImage(args[0].Int(), args[1].String(), data, args[3].String()); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if currentEditor.Revision() == revision {
|
if currentEditor.Revision() == revision {
|
||||||
@@ -1106,7 +1105,7 @@ func updatePathStyle(args []js.Value) (any, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// insertText 使用选定字体创建文字对象
|
// insertText 使用选定字体创建文字对象
|
||||||
// 入参: args 页码、内容、字体数据、横纵坐标、字号、颜色、框宽、折行、对齐和行距
|
// 入参: args 页码、内容、字体数据、横纵坐标、字号、颜色、框宽、折行、对齐、行距和字距
|
||||||
// 返回: any 文档信息, error 错误信息
|
// 返回: any 文档信息, error 错误信息
|
||||||
func insertText(args []js.Value) (any, error) {
|
func insertText(args []js.Value) (any, error) {
|
||||||
if currentEditor == nil {
|
if currentEditor == nil {
|
||||||
@@ -1139,7 +1138,7 @@ func insertText(args []js.Value) (any, error) {
|
|||||||
Boundary: fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H),
|
Boundary: fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H),
|
||||||
Font: fontID, Size: args[5].Float(),
|
Font: fontID, Size: args[5].Float(),
|
||||||
}}
|
}}
|
||||||
if err := currentEditor.LayoutText(&object.TextObject, args[1].String(), ofdgo.TextLayout{Wrap: args[8].Bool(), Align: args[9].String(), LineHeight: args[10].Float()}); err != nil {
|
if err := currentEditor.LayoutText(&object.TextObject, args[1].String(), ofdgo.TextLayout{Wrap: args[8].Bool(), Align: args[9].String(), LineHeight: args[10].Float(), LetterSpacing: args[11].Float()}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := setTextColor(&object.TextObject, args[6].String()); err != nil {
|
if err := setTextColor(&object.TextObject, args[6].String()); err != nil {
|
||||||
@@ -1152,7 +1151,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 {
|
||||||
@@ -1173,7 +1172,7 @@ func layoutText(args []js.Value) (any, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
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(), LetterSpacing: args[7].Float()}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
revision := currentEditor.Revision()
|
revision := currentEditor.Revision()
|
||||||
@@ -1230,6 +1229,59 @@ func cropImage(args []js.Value) (any, error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fitImage 按原始比例适应或填充图片框。
|
||||||
|
// 入参: args 页面索引、对象标识和适应方式
|
||||||
|
// 返回: any 文档信息, error 错误信息
|
||||||
|
func fitImage(args []js.Value) (any, error) {
|
||||||
|
return changeObjects(func() error {
|
||||||
|
return currentEditor.FitImage(args[0].Int(), args[1].String(), args[2].String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// previewImage 在页面副本中显示指定图片的完整内容,不改变正文、历史或缓存。
|
||||||
|
// 入参: args 页面索引和图片对象标识
|
||||||
|
// 返回: any 页面SVG和分离的图片资源, error 错误信息
|
||||||
|
func previewImage(args []js.Value) (any, error) {
|
||||||
|
if currentEditor == nil {
|
||||||
|
return nil, fmt.Errorf("no document is being created")
|
||||||
|
}
|
||||||
|
page, err := currentEditor.Page(args[0].Int())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
id := args[1].String()
|
||||||
|
found := false
|
||||||
|
for i := range page.Content.Layer {
|
||||||
|
for j := range page.Content.Layer[i].Objects {
|
||||||
|
object := &page.Content.Layer[i].Objects[j]
|
||||||
|
if object.Type == "ImageObject" && object.ImageObject.ID == id {
|
||||||
|
object.ImageObject.Clips = nil
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil, fmt.Errorf("image object %q not found", id)
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
resources, err := currentSession.Renderer.RenderToSVGWithResources(page, &out)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return successResult(map[string]any{"svg": out.String(), "images": svgImagesToJS(resources.Images)}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// svgImagesToJS 将图片资源以二进制数组传给前端,不使用base64。
|
||||||
|
// 入参: images SVG引用的图片资源
|
||||||
|
// 返回: []any 图片标识、类型和数据
|
||||||
|
func svgImagesToJS(images []ofdgo.SVGImage) []any {
|
||||||
|
items := make([]any, len(images))
|
||||||
|
for i, image := range images {
|
||||||
|
items[i] = map[string]any{"name": image.Name, "mime": image.MIME, "bytes": bytesToJS(image.Data)}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
// transformObjects 统一移动或缩放选区,生成一次撤销记录。
|
// transformObjects 统一移动或缩放选区,生成一次撤销记录。
|
||||||
// 入参: args 页面索引、对象标识数组、横纵位移和正缩放比例
|
// 入参: args 页面索引、对象标识数组、横纵位移和正缩放比例
|
||||||
// 返回: any 文档信息, error 错误信息
|
// 返回: any 文档信息, error 错误信息
|
||||||
|
|||||||
+22
-10
@@ -40,7 +40,7 @@ type Editor struct {
|
|||||||
pages []PageContent
|
pages []PageContent
|
||||||
resources []editorResource
|
resources []editorResource
|
||||||
fonts map[string]*font.SFNT
|
fonts map[string]*font.SFNT
|
||||||
images map[string]bool
|
images map[string]image.Point
|
||||||
resourceID map[editorResourceKey]string
|
resourceID map[editorResourceKey]string
|
||||||
maxID int
|
maxID int
|
||||||
history []editorChange
|
history []editorChange
|
||||||
@@ -77,7 +77,7 @@ func NewEditor() *Editor {
|
|||||||
CreationDate: time.Now().Format("2006-01-02"),
|
CreationDate: time.Now().Format("2006-01-02"),
|
||||||
},
|
},
|
||||||
fonts: make(map[string]*font.SFNT),
|
fonts: make(map[string]*font.SFNT),
|
||||||
images: make(map[string]bool),
|
images: make(map[string]image.Point),
|
||||||
resourceID: make(map[editorResourceKey]string),
|
resourceID: make(map[editorResourceKey]string),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -346,7 +346,7 @@ func (e *Editor) AddImage(data []byte) (string, error) {
|
|||||||
data: bytes.Clone(data),
|
data: bytes.Clone(data),
|
||||||
image: &MultiMedia{ID: id, Type: "Image", Format: strings.ToUpper(format), MediaFile: name},
|
image: &MultiMedia{ID: id, Type: "Image", Format: strings.ToUpper(format), MediaFile: name},
|
||||||
})
|
})
|
||||||
e.images[id] = true
|
e.images[id] = image.Pt(config.Width, config.Height)
|
||||||
e.resourceID[key] = id
|
e.resourceID[key] = id
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
@@ -554,7 +554,7 @@ func (e *Editor) prepareObject(id string, object GraphicObject) (GraphicObject,
|
|||||||
fill, stroke = obj.FillColor, (*FillColor)(obj.StrokeColor)
|
fill, stroke = obj.FillColor, (*FillColor)(obj.StrokeColor)
|
||||||
case "ImageObject":
|
case "ImageObject":
|
||||||
obj := &object.ImageObject
|
obj := &object.ImageObject
|
||||||
if !e.images[obj.ResourceID] || (obj.ImageMask != "" && !e.images[obj.ImageMask]) {
|
if e.images[obj.ResourceID].X == 0 || (obj.ImageMask != "" && e.images[obj.ImageMask].X == 0) {
|
||||||
return GraphicObject{}, fmt.Errorf("image resource not found")
|
return GraphicObject{}, fmt.Errorf("image resource not found")
|
||||||
}
|
}
|
||||||
if obj.Border != nil {
|
if obj.Border != nil {
|
||||||
@@ -720,6 +720,7 @@ func transformEditorObject(object GraphicObject, dx, dy, scale float64) (Graphic
|
|||||||
if obj.layout != nil {
|
if obj.layout != nil {
|
||||||
layout := *obj.layout
|
layout := *obj.layout
|
||||||
layout.options.LineHeight *= scale
|
layout.options.LineHeight *= scale
|
||||||
|
layout.options.LetterSpacing *= scale
|
||||||
obj.layout = &layout
|
obj.layout = &layout
|
||||||
}
|
}
|
||||||
if obj.LineWidth == 0 && obj.Stroke != nil && *obj.Stroke {
|
if obj.LineWidth == 0 && obj.Stroke != nil && *obj.Stroke {
|
||||||
@@ -756,10 +757,10 @@ func transformEditorObject(object GraphicObject, dx, dy, scale float64) (Graphic
|
|||||||
return object, nil
|
return object, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplaceImage 替换图片资源,保留对象位置、尺寸、变换和绘制顺序
|
// ReplaceImage 替换图片资源,保留对象标识和绘制顺序,一次撤销恢复资源及布局。
|
||||||
// 入参: page 页面索引, id 图片对象标识, data PNG或JPEG数据
|
// 入参: page 页面索引, id 图片对象标识, data PNG或JPEG数据, fit 为contain、cover或空,空值保留原变换和裁剪
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
func (e *Editor) ReplaceImage(page int, id string, data []byte) error {
|
func (e *Editor) ReplaceImage(page int, id string, data []byte, fit string) error {
|
||||||
object, err := e.Object(page, id)
|
object, err := e.Object(page, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -771,6 +772,11 @@ func (e *Editor) ReplaceImage(page int, id string, data []byte) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if fit != "" {
|
||||||
|
if err := e.fitImage(&object.ImageObject, fit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return e.UpdateObject(page, id, object)
|
return e.UpdateObject(page, id, object)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -819,6 +825,9 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e
|
|||||||
if !finite(lineHeight) || lineHeight < 0 {
|
if !finite(lineHeight) || lineHeight < 0 {
|
||||||
return fmt.Errorf("line height must be finite and nonnegative")
|
return fmt.Errorf("line height must be finite and nonnegative")
|
||||||
}
|
}
|
||||||
|
if !finite(options.LetterSpacing) {
|
||||||
|
return fmt.Errorf("letter spacing must be finite")
|
||||||
|
}
|
||||||
if !slices.Contains([]string{"", "left", "center", "right", "justify"}, options.Align) {
|
if !slices.Contains([]string{"", "left", "center", "right", "justify"}, options.Align) {
|
||||||
return fmt.Errorf("invalid text alignment %q", options.Align)
|
return fmt.Errorf("invalid text alignment %q", options.Align)
|
||||||
}
|
}
|
||||||
@@ -849,13 +858,16 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e
|
|||||||
return fmt.Errorf("font does not contain U+%04X", char)
|
return fmt.Errorf("font does not contain U+%04X", char)
|
||||||
}
|
}
|
||||||
advances[i] = float64(sfnt.GlyphAdvance(glyph)) * unit * hScale
|
advances[i] = float64(sfnt.GlyphAdvance(glyph)) * unit * hScale
|
||||||
if !finite(advances[i]) {
|
}
|
||||||
|
spaceTextAdvances(runes, advances, options.LetterSpacing)
|
||||||
|
for _, advance := range advances {
|
||||||
|
if !finite(advance) {
|
||||||
return fmt.Errorf("text advance exceeds finite range")
|
return fmt.Errorf("text advance exceeds finite range")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lines := breakTextLines(runes, advances, width, options.Wrap)
|
lines := breakTextLines(runes, advances, width, options.Wrap, options.LetterSpacing)
|
||||||
for i, line := range lines {
|
for i, line := range lines {
|
||||||
x, deltas := alignTextLine(runes[line[0]:line[1]], advances[line[0]:line[1]], width, options.Align, i+1 < len(lines))
|
x, deltas := alignTextLine(runes[line[0]:line[1]], advances[line[0]:line[1]], width, options.Align, i+1 < len(lines), options.LetterSpacing)
|
||||||
y := float64(ascender)*unit + float64(len(codes))*lineHeight
|
y := float64(ascender)*unit + float64(len(codes))*lineHeight
|
||||||
if !finite(x) || !finite(y) {
|
if !finite(x) || !finite(y) {
|
||||||
return fmt.Errorf("text position exceeds finite range")
|
return fmt.Errorf("text position exceeds finite range")
|
||||||
|
|||||||
+30
-7
@@ -21,11 +21,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// TextLayout 本地横向段落选项,Wrap按CTM变换前的边界宽度折行,Align为left、center、right或justify。
|
// TextLayout 本地横向段落选项,Wrap按CTM变换前的边界宽度折行,Align为left、center、right或justify。
|
||||||
// LineHeight为毫米单位的基线间距,0使用字体度量;零值保持显式换行和左对齐。
|
// LineHeight为毫米单位的基线间距,0使用字体度量;LetterSpacing为字素间的附加毫米间距,可为负。
|
||||||
|
// 零值保持显式换行和左对齐。
|
||||||
type TextLayout struct {
|
type TextLayout struct {
|
||||||
Wrap bool
|
Wrap bool
|
||||||
Align string
|
Align string
|
||||||
LineHeight float64
|
LineHeight float64
|
||||||
|
LetterSpacing float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// textLayout 保存编辑中的原文与选项,不写入OFD,也不参与渲染。
|
// textLayout 保存编辑中的原文与选项,不写入OFD,也不参与渲染。
|
||||||
@@ -45,9 +47,9 @@ func (obj TextObject) TextLayout() (string, TextLayout) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// breakTextLines 优先使用Unicode断行机会,过长词仅在字素边界折行,不丢弃空白。
|
// breakTextLines 优先使用Unicode断行机会,过长词仅在字素边界折行,不丢弃空白。
|
||||||
// 入参: runes 段落字符, advances 各字符步进, width 本地排版宽度, wrap 是否自动折行
|
// 入参: runes 段落字符, advances 含字距的字符步进, width 本地排版宽度, wrap 是否自动折行, spacing 附加字距
|
||||||
// 返回: [][2]int 各行的字符起止索引,左闭右开
|
// 返回: [][2]int 各行的字符起止索引,左闭右开
|
||||||
func breakTextLines(runes []rune, advances []float64, width float64, wrap bool) [][2]int {
|
func breakTextLines(runes []rune, advances []float64, width float64, wrap bool, spacing float64) [][2]int {
|
||||||
if !wrap || len(runes) == 0 {
|
if !wrap || len(runes) == 0 {
|
||||||
return [][2]int{{0, len(runes)}}
|
return [][2]int{{0, len(runes)}}
|
||||||
}
|
}
|
||||||
@@ -76,7 +78,11 @@ func breakTextLines(runes []rune, advances []float64, width float64, wrap bool)
|
|||||||
for visible > start && runes[visible-1] == ' ' {
|
for visible > start && runes[visible-1] == ' ' {
|
||||||
visible--
|
visible--
|
||||||
}
|
}
|
||||||
if sums[visible]-sums[start] > width && end > start {
|
length := sums[visible] - sums[start]
|
||||||
|
if visible > start {
|
||||||
|
length -= spacing
|
||||||
|
}
|
||||||
|
if length > width && end > start {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
end = candidate
|
end = candidate
|
||||||
@@ -84,7 +90,7 @@ func breakTextLines(runes []rune, advances []float64, width float64, wrap bool)
|
|||||||
if breaks[end] {
|
if breaks[end] {
|
||||||
legal = end
|
legal = end
|
||||||
}
|
}
|
||||||
if sums[visible]-sums[start] > width {
|
if length > width {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,9 +107,9 @@ func breakTextLines(runes []rune, advances []float64, width float64, wrap bool)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// alignTextLine 将段落对齐转换为标准X和DeltaX,两端对齐保留段落末行左对齐。
|
// alignTextLine 将段落对齐转换为标准X和DeltaX,两端对齐保留段落末行左对齐。
|
||||||
// 入参: runes 行内字符, advances 各字符步进, width 本地排版宽度, alignment 对齐方式, justify 是否允许本行两端对齐
|
// 入参: runes 行内字符, advances 含字距的字符步进, width 本地排版宽度, alignment 对齐方式, justify 是否允许本行两端对齐, spacing 附加字距
|
||||||
// 返回: float64 行首X坐标, string 字符间的DeltaX序列
|
// 返回: 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, spacing float64) (float64, string) {
|
||||||
end := len(runes)
|
end := len(runes)
|
||||||
for end > 0 && runes[end-1] == ' ' {
|
for end > 0 && runes[end-1] == ' ' {
|
||||||
end--
|
end--
|
||||||
@@ -112,6 +118,9 @@ func alignTextLine(runes []rune, advances []float64, width float64, alignment st
|
|||||||
for _, advance := range advances[:end] {
|
for _, advance := range advances[:end] {
|
||||||
length += advance
|
length += advance
|
||||||
}
|
}
|
||||||
|
if end > 0 {
|
||||||
|
length -= spacing
|
||||||
|
}
|
||||||
x, extra := 0.0, max(0, width-length)
|
x, extra := 0.0, max(0, width-length)
|
||||||
if alignment == "center" {
|
if alignment == "center" {
|
||||||
x = extra / 2
|
x = extra / 2
|
||||||
@@ -140,3 +149,17 @@ func alignTextLine(runes []rune, advances []float64, width float64, alignment st
|
|||||||
}
|
}
|
||||||
return x, strings.Join(deltas, " ")
|
return x, strings.Join(deltas, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// spaceTextAdvances 将字距加入各字素末尾,不拆散组合字符。
|
||||||
|
// 入参: runes 段落字符, advances 待更新的字符步进, spacing 附加毫米字距
|
||||||
|
func spaceTextAdvances(runes []rune, advances []float64, spacing float64) {
|
||||||
|
if spacing == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var seg segmenter.Segmenter
|
||||||
|
seg.Init(runes)
|
||||||
|
for it := seg.GraphemeIterator(); it.Next(); {
|
||||||
|
cluster := it.Grapheme()
|
||||||
|
advances[cluster.Offset+len(cluster.Text)-1] += spacing
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ package ofdgo
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"slices"
|
"slices"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -152,6 +153,16 @@ func (e *Editor) CropImage(page int, id string, box Box) error {
|
|||||||
return fmt.Errorf("object %q is not an image", id)
|
return fmt.Errorf("object %q is not an image", id)
|
||||||
}
|
}
|
||||||
image := &object.ImageObject
|
image := &object.ImageObject
|
||||||
|
if err := cropImageObject(image, box); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.UpdateObject(page, id, object)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cropImageObject 更新图片副本的裁剪和局部坐标,不修改资源。
|
||||||
|
// 入参: image 图片对象副本, box 页面毫米坐标中的保留范围
|
||||||
|
// 返回: error 错误信息
|
||||||
|
func cropImageObject(image *ImageObject, box Box) error {
|
||||||
full, err := image.ImageBounds()
|
full, err := image.ImageBounds()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -181,9 +192,66 @@ func (e *Editor) CropImage(page int, id string, box Box) error {
|
|||||||
path.Fill, path.Stroke = &fill, &stroke
|
path.Fill, path.Stroke = &fill, &stroke
|
||||||
image.Clips = &Clips{Clip: []Clip{{Area: []ClipArea{{CTM: inverse.String(), Path: []PathObject{path}}}}}}
|
image.Clips = &Clips{Clip: []Clip{{Area: []ClipArea{{CTM: inverse.String(), Path: []PathObject{path}}}}}}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FitImage 按原始像素比例居中适应或填充当前Boundary,保留直角旋转及镜像方向。
|
||||||
|
// 保留原始图片,不重采样;重设裁剪,一次撤销恢复原布局。
|
||||||
|
// 入参: page 页面索引, id 图片对象标识, mode 为contain(完整显示)或cover(填满裁剪)
|
||||||
|
// 返回: error 错误信息
|
||||||
|
func (e *Editor) FitImage(page int, id, mode string) 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)
|
||||||
|
}
|
||||||
|
if err := e.fitImage(&object.ImageObject, mode); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return e.UpdateObject(page, id, object)
|
return e.UpdateObject(page, id, object)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fitImage 将原始像素比例应用到当前图片框,适应时保留空白,填充时裁掉超出部分。
|
||||||
|
// 入参: obj 图片对象副本, mode 为contain或cover
|
||||||
|
// 返回: error 错误信息
|
||||||
|
func (e *Editor) fitImage(obj *ImageObject, mode string) error {
|
||||||
|
if mode != "contain" && mode != "cover" {
|
||||||
|
return fmt.Errorf("invalid image fit %q", mode)
|
||||||
|
}
|
||||||
|
box, err := creationBox(obj.Boundary)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m := NewMatrix(obj.CTM)
|
||||||
|
if !axisAlignedMatrix(m) {
|
||||||
|
return fmt.Errorf("image fitting requires an axis-aligned transform")
|
||||||
|
}
|
||||||
|
size := e.images[obj.ResourceID]
|
||||||
|
w, h := float64(size.X), float64(size.Y)
|
||||||
|
if m.a == 0 {
|
||||||
|
w, h = h, w
|
||||||
|
}
|
||||||
|
scale := math.Min(box.W/w, box.H/h)
|
||||||
|
if mode == "cover" {
|
||||||
|
scale = math.Max(box.W/w, box.H/h)
|
||||||
|
}
|
||||||
|
if m.a == 0 {
|
||||||
|
m.b, m.c = math.Copysign(float64(size.X)*scale, m.b), math.Copysign(float64(size.Y)*scale, m.c)
|
||||||
|
} else {
|
||||||
|
m.a, m.d = math.Copysign(w*scale, m.a), math.Copysign(h*scale, m.d)
|
||||||
|
}
|
||||||
|
m.e, m.f = 0, 0
|
||||||
|
full := m.TransformBox(Box{W: 1, H: 1})
|
||||||
|
m.e, m.f = (box.W-full.W)/2-full.X, (box.H-full.H)/2-full.Y
|
||||||
|
obj.CTM, obj.Clips = m.String(), nil
|
||||||
|
if mode == "cover" {
|
||||||
|
return cropImageObject(obj, box)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// TextFrame 将对象Boundary逆变换到本地坐标并返回轴对齐边界。
|
// TextFrame 将对象Boundary逆变换到本地坐标并返回轴对齐边界。
|
||||||
// 直角旋转与翻转不改变本地段落宽度。
|
// 直角旋转与翻转不改变本地段落宽度。
|
||||||
// 返回: Box 对象本地坐标中的边界, error 错误信息
|
// 返回: Box 对象本地坐标中的边界, error 错误信息
|
||||||
|
|||||||
Reference in new issue
Block a user