mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 13:56:32 +08:00
1 parent
0ab785ebc3
commit
8e31f888d3
19 files changed
+9092
-6499
No files matched your search
@@ -22,9 +22,14 @@ import (
|
|||||||
|
|
||||||
// FS WebUI嵌入静态文件
|
// FS WebUI嵌入静态文件
|
||||||
//
|
//
|
||||||
//go:embed index.html ofdgo.css ofdgo.js ofdgo.json ofdgo.svg ofdgo.wasm ofdgo_edit.js ofdgo_font.js ofdgo_wasm.js ofdgo_work.js wasm_exec.js
|
//go:embed index.html ofdgo.css ofdgo.js ofdgo.json ofdgo.svg ofdgo.wasm ofdgo_edit.js ofdgo_font.js ofdgo_host.js ofdgo_wasm.js ofdgo_work.js wasm_exec.js
|
||||||
var FS embed.FS
|
var FS embed.FS
|
||||||
|
|
||||||
|
// NativeFS WebView嵌入静态文件
|
||||||
|
//
|
||||||
|
//go:embed index.html ofdgo.css ofdgo.js ofdgo.json ofdgo.svg ofdgo_edit.js ofdgo_font.js ofdgo_host.js
|
||||||
|
var NativeFS embed.FS
|
||||||
|
|
||||||
// Checksum 计算WebUI嵌入资源校验值
|
// Checksum 计算WebUI嵌入资源校验值
|
||||||
// 返回: string SHA-256十六进制校验值
|
// 返回: string SHA-256十六进制校验值
|
||||||
func Checksum() string {
|
func Checksum() string {
|
||||||
|
|||||||
+31
-16
@@ -1,5 +1,6 @@
|
|||||||
import { SVG_NAMESPACE, CanvasEditor, canEditObject, editTextValue, richTextModel, fontFaceStyle, missingGlyphMessage, objectEditReason, selectedText, lineShape, selectionBounds, pagePoint } from "./ofdgo_edit.js";
|
import { SVG_NAMESPACE, CanvasEditor, canEditObject, editTextValue, richTextModel, fontFaceStyle, missingGlyphMessage, objectEditReason, selectedText, lineShape, selectionBounds, pagePoint } from "./ofdgo_edit.js";
|
||||||
import { FontManager, FontPicker } from "./ofdgo_font.js";
|
import { FontManager, FontPicker } from "./ofdgo_font.js";
|
||||||
|
import { nativeHost, createEngineWorker, documentBytes, saveFilePicker, directoryPicker } from "./ofdgo_host.js";
|
||||||
|
|
||||||
const MM_TO_PX = 96 / 25.4;
|
const MM_TO_PX = 96 / 25.4;
|
||||||
const COMPACT_LAYOUT = window.matchMedia("(max-width: 900px)");
|
const COMPACT_LAYOUT = window.matchMedia("(max-width: 900px)");
|
||||||
@@ -595,6 +596,7 @@ const el = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fontManager = new FontManager({
|
const fontManager = new FontManager({
|
||||||
|
host: nativeHost,
|
||||||
onChange: scheduleFontSync,
|
onChange: scheduleFontSync,
|
||||||
onPermissionChange: updateFontPermissionHint,
|
onPermissionChange: updateFontPermissionHint,
|
||||||
});
|
});
|
||||||
@@ -1388,8 +1390,8 @@ batchElements.Cancel.addEventListener("click", cancelBatch);
|
|||||||
batchElements.Form.addEventListener("submit", event => { event.preventDefault(); runBatch(); });
|
batchElements.Form.addEventListener("submit", event => { event.preventDefault(); runBatch(); });
|
||||||
batchElements.Panel.addEventListener("dragover", event => { event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = batch.running ? "none" : "copy"; });
|
batchElements.Panel.addEventListener("dragover", event => { event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = batch.running ? "none" : "copy"; });
|
||||||
batchElements.Panel.addEventListener("drop", event => { event.preventDefault(); event.stopPropagation(); if (!batch.running) addBatchFiles(event.dataTransfer.files); });
|
batchElements.Panel.addEventListener("drop", event => { event.preventDefault(); event.stopPropagation(); if (!batch.running) addBatchFiles(event.dataTransfer.files); });
|
||||||
batchElements.Destination.querySelector('[value="directory"]').disabled = !window.showDirectoryPicker;
|
batchElements.Destination.querySelector('[value="directory"]').disabled = !nativeHost && !window.showDirectoryPicker;
|
||||||
batchElements.Destination.value = window.showDirectoryPicker ? "directory" : "archive";
|
batchElements.Destination.value = nativeHost || window.showDirectoryPicker ? "directory" : "archive";
|
||||||
fontBindingElements.Cancel.addEventListener("click", () => fontBindingElements.Panel.close());
|
fontBindingElements.Cancel.addEventListener("click", () => fontBindingElements.Panel.close());
|
||||||
fontBindingElements.Panel.addEventListener("close", () => { fontBinding.font = null; fontBinding.choices = []; fontBinding.sequence++; fontBindingElements.File.value = ""; });
|
fontBindingElements.Panel.addEventListener("close", () => { fontBinding.font = null; fontBinding.choices = []; fontBinding.sequence++; fontBindingElements.File.value = ""; });
|
||||||
fontBindingElements.Source.addEventListener("change", () => {
|
fontBindingElements.Source.addEventListener("change", () => {
|
||||||
@@ -1703,6 +1705,7 @@ document.addEventListener("visibilitychange", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
el.fontDirectoryButton.disabled = !("webkitdirectory" in el.fontDirectoryInput);
|
el.fontDirectoryButton.disabled = !("webkitdirectory" in el.fontDirectoryInput);
|
||||||
|
nativeHost?.installFileInputs(error => showError(error, false));
|
||||||
updateSidebarState();
|
updateSidebarState();
|
||||||
boot();
|
boot();
|
||||||
|
|
||||||
@@ -2212,7 +2215,7 @@ async function loadImportFile() {
|
|||||||
el.importStatus.textContent = "";
|
el.importStatus.textContent = "";
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const data = file ? new Uint8Array(await file.arrayBuffer()) : null;
|
const data = file ? await documentBytes(file) : null;
|
||||||
if (openSeq !== state.openSeq) return;
|
if (openSeq !== state.openSeq) return;
|
||||||
const info = data ? await openWithCredentials(null, openSeq, data, true) : await callWASM("ofdgoLoadImport", null);
|
const info = data ? await openWithCredentials(null, openSeq, data, true) : await callWASM("ofdgoLoadImport", null);
|
||||||
if (openSeq !== state.openSeq || !el.importPanel.open) {
|
if (openSeq !== state.openSeq || !el.importPanel.open) {
|
||||||
@@ -2461,7 +2464,7 @@ async function exportManagedVersion() {
|
|||||||
setBusy(true, "正在另存版本");
|
setBusy(true, "正在另存版本");
|
||||||
el.versionStatus.textContent = "正在另存版本";
|
el.versionStatus.textContent = "正在另存版本";
|
||||||
try {
|
try {
|
||||||
const file = window.showSaveFilePicker ? await window.showSaveFilePicker({suggestedName: name, types: [{description: "OFD", accept: {"application/ofd": [".ofd"]}}]}) : null;
|
const file = nativeHost || window.showSaveFilePicker ? await saveFilePicker({suggestedName: name, types: [{description: "OFD", accept: {"application/ofd": [".ofd"]}}]}) : null;
|
||||||
if (openSeq !== state.openSeq) return;
|
if (openSeq !== state.openSeq) return;
|
||||||
const result = await callWASM("ofdgoExportVersion", entry.id, file);
|
const result = await callWASM("ofdgoExportVersion", entry.id, file);
|
||||||
if (openSeq !== state.openSeq) return;
|
if (openSeq !== state.openSeq) return;
|
||||||
@@ -4126,6 +4129,12 @@ async function boot() {
|
|||||||
}).catch((err) => showError(err, !state.doc));
|
}).catch((err) => showError(err, !state.doc));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (nativeHost) await nativeHost.start(files => {
|
||||||
|
if (document.body.hasAttribute("data-busy")) { setStatus("请等待当前操作完成"); return; }
|
||||||
|
if (batchElements.Panel.open) addBatchFiles(files);
|
||||||
|
else if (files.length === 1) openOFD(files[0]);
|
||||||
|
else if (files.length) setStatus("请选择一个文档");
|
||||||
|
}, error => showError(error, !state.doc));
|
||||||
try {
|
try {
|
||||||
const remote = remoteDocumentOptions(window.location.hash);
|
const remote = remoteDocumentOptions(window.location.hash);
|
||||||
if (remote && openSeq === state.openSeq) await openOFD({ name: remote.name }, remote);
|
if (remote && openSeq === state.openSeq) await openOFD({ name: remote.name }, remote);
|
||||||
@@ -4154,6 +4163,11 @@ async function registerOffline() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function prepareOffline() {
|
async function prepareOffline() {
|
||||||
|
if (nativeHost) {
|
||||||
|
el.appPanel.hidden = false;
|
||||||
|
el.offlineStatus.textContent = "可离线";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (!window.isSecureContext || !("serviceWorker" in navigator) || !("caches" in window) || !("locks" in navigator)) {
|
if (!window.isSecureContext || !("serviceWorker" in navigator) || !("caches" in window) || !("locks" in navigator)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -4323,7 +4337,7 @@ async function loadWASM() {
|
|||||||
state.ready = false;
|
state.ready = false;
|
||||||
state.wasmExited = false;
|
state.wasmExited = false;
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const worker = new Worker("./ofdgo_wasm.js");
|
const worker = createEngineWorker();
|
||||||
wasmWorker = worker;
|
wasmWorker = worker;
|
||||||
const fail = (err) => {
|
const fail = (err) => {
|
||||||
markWASMExited(wasmSeq, err);
|
markWASMExited(wasmSeq, err);
|
||||||
@@ -4557,7 +4571,7 @@ async function openOFD(file, remote = null) {
|
|||||||
const source = downloaded ? downloaded.file : file;
|
const source = downloaded ? downloaded.file : file;
|
||||||
const format = await callWASM("ofdgoDetectFormat", source);
|
const format = await callWASM("ofdgoDetectFormat", source);
|
||||||
if (openSeq !== state.openSeq) return;
|
if (openSeq !== state.openSeq) return;
|
||||||
let bytes = new Uint8Array(await source.arrayBuffer());
|
let bytes = await documentBytes(source);
|
||||||
const name = (downloaded ? downloaded.name : file.name) || `ofdgo.${format}`;
|
const name = (downloaded ? downloaded.name : file.name) || `ofdgo.${format}`;
|
||||||
let warnings = [];
|
let warnings = [];
|
||||||
let convertedDoc;
|
let convertedDoc;
|
||||||
@@ -5108,7 +5122,7 @@ async function openWithCredentials(fonts, openSeq, data = state.ofdBytes, import
|
|||||||
converted.doc = await callWASM("ofdgoOpen", converted.bytes, fonts, state.renderAnnotations, credentials);
|
converted.doc = await callWASM("ofdgoOpen", converted.bytes, fonts, state.renderAnnotations, credentials);
|
||||||
return converted;
|
return converted;
|
||||||
}
|
}
|
||||||
return importing ? await callWASM("ofdgoLoadImport", data.slice(), credentials)
|
return importing ? await callWASM("ofdgoLoadImport", data?.nativeFile ? data : data.slice(), credentials)
|
||||||
: await callWASM("ofdgoOpen", data, fonts, state.renderAnnotations, credentials);
|
: await callWASM("ofdgoOpen", data, fonts, state.renderAnnotations, credentials);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (openSeq !== state.openSeq || !(pdf ? err.code === "pdfPassword" : ["credentialsRequired", "invalidCredentials"].includes(err.code))) throw err;
|
if (openSeq !== state.openSeq || !(pdf ? err.code === "pdfPassword" : ["credentialsRequired", "invalidCredentials"].includes(err.code))) throw err;
|
||||||
@@ -7529,7 +7543,7 @@ async function downloadResource(attachment, api, label) {
|
|||||||
updateControls();
|
updateControls();
|
||||||
setBusy(true, `正在读取${label}`, null, `正在读取${label}`);
|
setBusy(true, `正在读取${label}`, null, `正在读取${label}`);
|
||||||
try {
|
try {
|
||||||
const file = window.showSaveFilePicker ? await window.showSaveFilePicker({ suggestedName: attachment.fileName }) : null;
|
const file = nativeHost || window.showSaveFilePicker ? await saveFilePicker({ suggestedName: attachment.fileName }) : null;
|
||||||
if (openSeq !== state.openSeq) {
|
if (openSeq !== state.openSeq) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -7848,7 +7862,7 @@ async function signDocument(event) {
|
|||||||
el.signForm.querySelectorAll("input, select, button[type=submit]").forEach(input => { input.disabled = true; });
|
el.signForm.querySelectorAll("input, select, button[type=submit]").forEach(input => { input.disabled = true; });
|
||||||
try {
|
try {
|
||||||
const name = `${baseFileName()}_签章.ofd`;
|
const name = `${baseFileName()}_签章.ofd`;
|
||||||
const file = window.showSaveFilePicker ? await window.showSaveFilePicker({suggestedName:name, types:[{description:"OFD",accept:{"application/ofd":[".ofd"]}}]}) : null;
|
const file = nativeHost || window.showSaveFilePicker ? await saveFilePicker({suggestedName:name, types:[{description:"OFD",accept:{"application/ofd":[".ofd"]}}]}) : null;
|
||||||
if (openSeq !== state.openSeq || state.signCanceled) return;
|
if (openSeq !== state.openSeq || state.signCanceled) return;
|
||||||
if (canvasEditor.input || canvasEditor.crop || canvasEditor.nudge || canvasEditor.nudgeCommit) {
|
if (canvasEditor.input || canvasEditor.crop || canvasEditor.nudge || canvasEditor.nudgeCommit) {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -8123,7 +8137,7 @@ function batchCall(name, args, onProgress) {
|
|||||||
function startBatchWorker() {
|
function startBatchWorker() {
|
||||||
batch.workerError = null;
|
batch.workerError = null;
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const worker = new Worker("./ofdgo_wasm.js");
|
const worker = createEngineWorker();
|
||||||
batch.worker = worker;
|
batch.worker = worker;
|
||||||
const fail = async error => {
|
const fail = async error => {
|
||||||
batch.workerError = error;
|
batch.workerError = error;
|
||||||
@@ -8224,13 +8238,13 @@ async function runBatch() {
|
|||||||
const entries = [];
|
const entries = [];
|
||||||
try {
|
try {
|
||||||
if (archive) {
|
if (archive) {
|
||||||
if (window.showSaveFilePicker) zipFile = await window.showSaveFilePicker({ suggestedName: "转换结果.zip", types: [{ description: "ZIP", accept: { "application/zip": [".zip"] } }] });
|
zipFile = await saveFilePicker({ suggestedName: "转换结果.zip", types: [{ description: "ZIP", accept: { "application/zip": [".zip"] } }] });
|
||||||
if (navigator.storage?.getDirectory) {
|
if (!nativeHost && navigator.storage?.getDirectory) {
|
||||||
storage = await navigator.storage.getDirectory();
|
storage = await navigator.storage.getDirectory();
|
||||||
stagingName = `ofdgo-convert-${crypto.randomUUID()}`;
|
stagingName = `ofdgo-convert-${crypto.randomUUID()}`;
|
||||||
destination = await storage.getDirectoryHandle(stagingName, { create: true });
|
destination = await storage.getDirectoryHandle(stagingName, { create: true });
|
||||||
}
|
}
|
||||||
} else destination = await window.showDirectoryPicker({ mode: "readwrite" });
|
} else destination = await directoryPicker();
|
||||||
if (batch.canceled) return;
|
if (batch.canceled) return;
|
||||||
if (jobs.some(item => /\.pdf$/i.test(item.file.name) || !["ofd", "txt"].includes(item.format || defaultFormat.value)
|
if (jobs.some(item => /\.pdf$/i.test(item.file.name) || !["ofd", "txt"].includes(item.format || defaultFormat.value)
|
||||||
|| (item.format || defaultFormat.value) === "ofd" && batchElements.FontMode.value === "1")) {
|
|| (item.format || defaultFormat.value) === "ofd" && batchElements.FontMode.value === "1")) {
|
||||||
@@ -8357,7 +8371,7 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value)
|
|||||||
updateControls();
|
updateControls();
|
||||||
setBusy(true, "正在生成文件", null, status);
|
setBusy(true, "正在生成文件", null, status);
|
||||||
try {
|
try {
|
||||||
const file = window.showSaveFilePicker ? await window.showSaveFilePicker({
|
const file = nativeHost || window.showSaveFilePicker ? await saveFilePicker({
|
||||||
suggestedName: fileName,
|
suggestedName: fileName,
|
||||||
types: [{ description: label, accept: { [mime]: [`.${extension}`] } }],
|
types: [{ description: label, accept: { [mime]: [`.${extension}`] } }],
|
||||||
}) : null;
|
}) : null;
|
||||||
@@ -11982,11 +11996,11 @@ async function callWASM(name, ...args) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const transfer = [];
|
const transfer = [];
|
||||||
if (name === "ofdgoOpen") {
|
if (name === "ofdgoOpen" && !nativeHost) {
|
||||||
args[0] = args[0].slice();
|
args[0] = args[0].slice();
|
||||||
transfer.push(args[0].buffer);
|
transfer.push(args[0].buffer);
|
||||||
}
|
}
|
||||||
if (name === "ofdgoLoadImport" && args[0]) transfer.push(args[0].buffer);
|
if (name === "ofdgoLoadImport" && args[0] && !nativeHost) transfer.push(args[0].buffer);
|
||||||
wasmWorker.postMessage({ id, name, args }, transfer);
|
wasmWorker.postMessage({ id, name, args }, transfer);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
wasmRequests.delete(id);
|
wasmRequests.delete(id);
|
||||||
@@ -12081,6 +12095,7 @@ function formatSize(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function downloadBytes(bytes, mime, name) {
|
function downloadBytes(bytes, mime, name) {
|
||||||
|
if (nativeHost) return nativeHost.download(bytes, mime, name).catch(error => { if (error.name !== "AbortError") showError(error, false); });
|
||||||
const blob = new Blob([bytes], { type: mime });
|
const blob = new Blob([bytes], { type: mime });
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|||||||
@@ -159,7 +159,8 @@ export class FontPicker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class FontManager {
|
export class FontManager {
|
||||||
constructor({ onChange, onPermissionChange }) {
|
constructor({ onChange, onPermissionChange, host = null }) {
|
||||||
|
this.host = host;
|
||||||
this.userFonts = [];
|
this.userFonts = [];
|
||||||
this.localFonts = [];
|
this.localFonts = [];
|
||||||
this.catalog = [];
|
this.catalog = [];
|
||||||
@@ -267,7 +268,7 @@ export class FontManager {
|
|||||||
|
|
||||||
async queryLocal() {
|
async queryLocal() {
|
||||||
try {
|
try {
|
||||||
const available = await window.queryLocalFonts();
|
const available = this.host ? await this.host.fonts() : await window.queryLocalFonts();
|
||||||
this.catalog = available;
|
this.catalog = available;
|
||||||
this.catalogLoaded = true;
|
this.catalogLoaded = true;
|
||||||
this.permission = "granted";
|
this.permission = "granted";
|
||||||
@@ -294,6 +295,7 @@ export class FontManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async read(font) {
|
async read(font) {
|
||||||
|
if (font.nativeFile) return font.nativeFile;
|
||||||
if (font.blob) {
|
if (font.blob) {
|
||||||
const blob = await font.blob();
|
const blob = await font.blob();
|
||||||
return new Uint8Array(await blob.arrayBuffer());
|
return new Uint8Array(await blob.arrayBuffer());
|
||||||
@@ -407,10 +409,15 @@ export class FontManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
canReadLocal() {
|
canReadLocal() {
|
||||||
return typeof window.queryLocalFonts === "function";
|
return !!this.host || typeof window.queryLocalFonts === "function";
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshPermission() {
|
async refreshPermission() {
|
||||||
|
if (this.host) {
|
||||||
|
this.permission = "granted";
|
||||||
|
this.onPermissionChange();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!this.canReadLocal() || !navigator.permissions?.query) {
|
if (!this.canReadLocal() || !navigator.permissions?.query) {
|
||||||
this.onPermissionChange();
|
this.onPermissionChange();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
export function createEngineWorker() {
|
||||||
|
return nativeHost ? nativeHost.worker() : new Worker("./ofdgo_wasm.js");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function documentBytes(file) {
|
||||||
|
return file?.nativeFile ? file : new Uint8Array(await file.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveFilePicker(options) {
|
||||||
|
return nativeHost ? nativeHost.savePicker(options) : window.showSaveFilePicker ? window.showSaveFilePicker(options) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function directoryPicker() {
|
||||||
|
return nativeHost ? nativeHost.directoryPicker() : window.showDirectoryPicker({mode:"readwrite"});
|
||||||
|
}
|
||||||
|
|
||||||
|
class NativeFile {
|
||||||
|
constructor(value) {
|
||||||
|
Object.assign(this, value);
|
||||||
|
this.byteLength = this.size;
|
||||||
|
this.type ||= "application/octet-stream";
|
||||||
|
if (value.temporary) nativeHost?.resources?.register(this, value.nativeFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
async arrayBuffer() {
|
||||||
|
const response = await fetch(this.url);
|
||||||
|
if (!response.ok) throw new Error("读取文件失败");
|
||||||
|
return response.arrayBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async text() {
|
||||||
|
return new TextDecoder().decode(await this.arrayBuffer());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NativeHandle {
|
||||||
|
constructor(host, value) {
|
||||||
|
this.host = host;
|
||||||
|
Object.assign(this, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFile() {
|
||||||
|
return new NativeFile(await this.host.call("file", {handle:this.nativeHandle}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFileHandle(name, options = {}) {
|
||||||
|
return new NativeHandle(this.host, await this.host.call("child", {handle:this.nativeHandle, name, create:!!options.create, directory:false}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDirectoryHandle(name, options = {}) {
|
||||||
|
return new NativeHandle(this.host, await this.host.call("child", {handle:this.nativeHandle, name, create:!!options.create, directory:true}));
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEntry(name, options = {}) {
|
||||||
|
return this.host.call("remove", {handle:this.nativeHandle, name, recursive:!!options.recursive});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createWritable() {
|
||||||
|
const {writer} = await this.host.call("write.begin", {handle:this.nativeHandle});
|
||||||
|
return {
|
||||||
|
write: async data => this.host.call("write.data", {writer, data:await this.host.encode(data)}),
|
||||||
|
close: () => this.host.call("write.commit", {writer}),
|
||||||
|
abort: () => this.host.call("write.abort", {writer}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NativeHost {
|
||||||
|
constructor(bridge) {
|
||||||
|
this.bridge = bridge;
|
||||||
|
this.resources = typeof FinalizationRegistry === "function" ? new FinalizationRegistry(file => {
|
||||||
|
this.call("release", {file}).catch(() => {});
|
||||||
|
}) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
call(name, value) {
|
||||||
|
return this.bridge.call(`ofdgo.host.${name}`, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async encode(value, key = "") {
|
||||||
|
if (value?.nativeFile) return {nativeFile:value.nativeFile, size:value.size, name:value.name};
|
||||||
|
if (value?.nativeHandle) return {nativeHandle:value.nativeHandle, name:value.name, kind:value.kind};
|
||||||
|
if (value instanceof Blob || value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
|
||||||
|
if (!(value instanceof Blob)) {
|
||||||
|
const bytes = ArrayBuffer.isView(value) ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) : new Uint8Array(value);
|
||||||
|
if (bytes.byteLength <= 65536 || ["password", "key", "keyPassword", "certificate"].includes(key)) return {nativeBytes:Array.from(bytes)};
|
||||||
|
}
|
||||||
|
const response = await fetch("/__native/input", {method:"POST", body:value});
|
||||||
|
if (!response.ok) throw new Error("传递数据失败");
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) return Promise.all(value.map(item => this.encode(item)));
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
const result = {};
|
||||||
|
for (const [key, item] of Object.entries(value)) result[key] = await this.encode(item, key);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async decode(value, name = "", key = "") {
|
||||||
|
if (value?.nativeFile) {
|
||||||
|
const file = new NativeFile(value);
|
||||||
|
if (key === "file" || name === "ofdgoConvertPDF" && key === "bytes") return file;
|
||||||
|
try {
|
||||||
|
const bytes = await file.arrayBuffer();
|
||||||
|
return key === "blob" ? new Blob([bytes], {type:value.type}) : new Uint8Array(bytes);
|
||||||
|
} finally {
|
||||||
|
await this.call("release", {file:value.nativeFile});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) return Promise.all(value.map(item => this.decode(item, name)));
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
const result = {};
|
||||||
|
for (const [field, item] of Object.entries(value)) result[field] = await this.decode(item, name, field);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
worker() {
|
||||||
|
const host = this;
|
||||||
|
let closed = false;
|
||||||
|
let unsubscribe;
|
||||||
|
let pending = Promise.resolve();
|
||||||
|
const canceled = new Set();
|
||||||
|
const emit = data => { if (!closed) worker.onmessage?.({data}); };
|
||||||
|
const ready = host.call("engine.open", {}).then(({engine}) => {
|
||||||
|
if (closed) { host.call("engine.close", {engine}).catch(() => {}); return engine; }
|
||||||
|
unsubscribe = host.bridge.on(`ofdgo.engine.${engine}`, emit);
|
||||||
|
emit({type:"ready"});
|
||||||
|
return engine;
|
||||||
|
}).catch(error => { if (!closed) worker.onerror?.({message:error.message}); throw error; });
|
||||||
|
const worker = {
|
||||||
|
onmessage:null,
|
||||||
|
onerror:null,
|
||||||
|
postMessage(data) {
|
||||||
|
if (closed) return;
|
||||||
|
if (data.type === "cancel") {
|
||||||
|
canceled.add(data.id);
|
||||||
|
ready.then(engine => host.call("engine.cancel", {engine, id:data.id})).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.type === "font") return;
|
||||||
|
pending = pending.then(async () => {
|
||||||
|
const engine = await ready;
|
||||||
|
if (closed) return;
|
||||||
|
if (canceled.has(data.id)) throw Object.assign(new Error("操作已取消"), {name:"AbortError"});
|
||||||
|
const args = await host.encode(data.args);
|
||||||
|
if (closed) return;
|
||||||
|
if (canceled.has(data.id)) throw Object.assign(new Error("操作已取消"), {name:"AbortError"});
|
||||||
|
const result = await host.call("engine.call", {engine, id:data.id, name:data.name, args});
|
||||||
|
emit({id:data.id, ...await host.decode(result, data.name)});
|
||||||
|
}).catch(error => emit({id:data.id, ok:false, error:error.message, code:error.code, canceled:error.name === "AbortError"})).finally(() => canceled.delete(data.id));
|
||||||
|
},
|
||||||
|
terminate() {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
unsubscribe?.();
|
||||||
|
ready.then(engine => host.call("engine.close", {engine})).catch(() => {});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ready.catch(() => {});
|
||||||
|
return worker;
|
||||||
|
}
|
||||||
|
|
||||||
|
async savePicker(options) {
|
||||||
|
return new NativeHandle(this, await this.call("save", options));
|
||||||
|
}
|
||||||
|
|
||||||
|
async directoryPicker() {
|
||||||
|
return new NativeHandle(this, await this.call("directory", {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fonts() {
|
||||||
|
return (await this.call("fonts", {})).map(font => ({...font, nativeFile:new NativeFile(font.file)}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(onFiles, onError) {
|
||||||
|
this.bridge.on("ofdgo.files", values => onFiles(values.map(value => new NativeFile(value))));
|
||||||
|
this.bridge.on("ofdgo.error", message => onError(new Error(message)));
|
||||||
|
const title = document.querySelector("title");
|
||||||
|
if (title) new MutationObserver(() => this.call("title", {title:document.title}).catch(onError)).observe(title, {childList:true});
|
||||||
|
await this.call("ready", {});
|
||||||
|
}
|
||||||
|
|
||||||
|
installFileInputs(onError) {
|
||||||
|
document.addEventListener("click", async event => {
|
||||||
|
const input = event.target;
|
||||||
|
if (!(input instanceof HTMLInputElement) || input.type !== "file" || input.disabled) return;
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
const files = (await this.call("open", {multiple:input.multiple, directory:input.webkitdirectory, accept:input.accept})).map(value => new NativeFile(value));
|
||||||
|
if (!files.length) return;
|
||||||
|
const transfer = new DataTransfer();
|
||||||
|
for (const file of files) transfer.items.add(new File([], file.name, {type:file.type}));
|
||||||
|
delete input.files;
|
||||||
|
input.files = transfer.files;
|
||||||
|
Object.defineProperty(input, "files", {configurable:true, get:() => input.value ? files : []});
|
||||||
|
input.dispatchEvent(new Event("input", {bubbles:true}));
|
||||||
|
input.dispatchEvent(new Event("change", {bubbles:true}));
|
||||||
|
} catch (error) { if (error.name !== "AbortError") onError(error); }
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async download(data, mime, name) {
|
||||||
|
const handle = await this.savePicker({suggestedName:name});
|
||||||
|
const writer = await handle.createWritable();
|
||||||
|
try { await writer.write(data); await writer.close(); }
|
||||||
|
catch (error) { await writer.abort().catch(() => {}); throw error; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nativeHost = globalThis.guigo ? new NativeHost(globalThis.guigo) : null;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
const CACHE_PREFIX = `ofdgo:${self.registration.scope}:app:`;
|
const CACHE_PREFIX = `ofdgo:${self.registration.scope}:app:`;
|
||||||
const META_CACHE = `${CACHE_PREFIX}meta`;
|
const META_CACHE = `${CACHE_PREFIX}meta`;
|
||||||
const ASSETS = ["./", "ofdgo.css", "ofdgo.js", "ofdgo.json", "ofdgo.svg", "ofdgo.wasm", "ofdgo_edit.js", "ofdgo_font.js", "ofdgo_wasm.js", "ofdgo_work.js", "wasm_exec.js"]
|
const ASSETS = ["./", "ofdgo.css", "ofdgo.js", "ofdgo.json", "ofdgo.svg", "ofdgo.wasm", "ofdgo_edit.js", "ofdgo_font.js", "ofdgo_host.js", "ofdgo_wasm.js", "ofdgo_work.js", "wasm_exec.js"]
|
||||||
.map((path) => new URL(path, self.registration.scope).href);
|
.map((path) => new URL(path, self.registration.scope).href);
|
||||||
const CLIENT_PREFIX = new URL("__client/", self.registration.scope).href;
|
const CLIENT_PREFIX = new URL("__client/", self.registration.scope).href;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
//go:build windows && amd64
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/xiaoqidun/ofdgo/internal/webview"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dataDir := flag.String("data-dir", "", "browser data directory")
|
||||||
|
flag.Parse()
|
||||||
|
if err := webview.Run(context.Background(), flag.Args(), *dataDir); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
message, _ := syscall.UTF16PtrFromString(err.Error())
|
||||||
|
title, _ := syscall.UTF16PtrFromString("OFDGo")
|
||||||
|
syscall.NewLazyDLL("user32.dll").NewProc("MessageBoxW").Call(0, uintptr(unsafe.Pointer(message)), uintptr(unsafe.Pointer(title)), 0x10)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
//go:build !windows || !amd64
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Fprintln(os.Stderr, "OFDGo WebView Unsupported")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ require (
|
|||||||
github.com/go-text/typesetting v0.3.5
|
github.com/go-text/typesetting v0.3.5
|
||||||
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c
|
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c
|
||||||
github.com/tdewolff/font v0.0.0-20261010204837-88a7f6b56918
|
github.com/tdewolff/font v0.0.0-20261010204837-88a7f6b56918
|
||||||
|
github.com/xiaoqidun/guigo v0.0.0-20261017082403-15e0454d4ca3
|
||||||
github.com/xiaoqidun/j2kgo v1.0.2
|
github.com/xiaoqidun/j2kgo v1.0.2
|
||||||
github.com/xiaoqidun/jbig2 v1.1.8
|
github.com/xiaoqidun/jbig2 v1.1.8
|
||||||
github.com/xiaoqidun/pdfgo v0.0.0-20261016003351-18348c283ac1
|
github.com/xiaoqidun/pdfgo v0.0.0-20261016003351-18348c283ac1
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ github.com/tdewolff/parse/v2 v2.8.16 h1:bLk5svUOQRkW/Y2SJ+DeENSIkZBcTIkq+Atyv5D8
|
|||||||
github.com/tdewolff/parse/v2 v2.8.16/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ=
|
github.com/tdewolff/parse/v2 v2.8.16/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ=
|
||||||
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
|
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
|
||||||
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||||
|
github.com/xiaoqidun/guigo v0.0.0-20261017082403-15e0454d4ca3 h1:hyp9meRTDfe22ox/s4n3ZcBWV8CHR+JURoiDAhKSDSg=
|
||||||
|
github.com/xiaoqidun/guigo v0.0.0-20261017082403-15e0454d4ca3/go.mod h1:QAHK2Sa/KvY6dO+6dtvVUeOXVQO4Zh8R5oSSU+av7kE=
|
||||||
github.com/xiaoqidun/j2kgo v1.0.2 h1:OAjjEOfaBaQzBNcj9wx+eM68UI5pewR6SzNZa1R1DIw=
|
github.com/xiaoqidun/j2kgo v1.0.2 h1:OAjjEOfaBaQzBNcj9wx+eM68UI5pewR6SzNZa1R1DIw=
|
||||||
github.com/xiaoqidun/j2kgo v1.0.2/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA=
|
github.com/xiaoqidun/j2kgo v1.0.2/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA=
|
||||||
github.com/xiaoqidun/jbig2 v1.1.8 h1:dERPW7F8gs18a/DOD9gXHR3fP1Vl6KhCNsLnq5l3BIo=
|
github.com/xiaoqidun/jbig2 v1.1.8 h1:dERPW7F8gs18a/DOD9gXHR3fP1Vl6KhCNsLnq5l3BIo=
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ func NormalizeName(name string) string {
|
|||||||
// 返回: bool 是否为字体文件
|
// 返回: bool 是否为字体文件
|
||||||
func IsFontFileName(name string) bool {
|
func IsFontFileName(name string) bool {
|
||||||
switch strings.ToLower(path.Ext(name)) {
|
switch strings.ToLower(path.Ext(name)) {
|
||||||
case ".ttf", ".otf", ".ttc":
|
case ".ttf", ".otf", ".ttc", ".otc":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,908 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
//go:build !js || !wasm
|
||||||
|
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/xiaoqidun/ofdgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
platformTypeUndefined = iota
|
||||||
|
platformTypeNumber
|
||||||
|
platformTypeString
|
||||||
|
platformTypeBoolean
|
||||||
|
platformTypeObject
|
||||||
|
platformTypeFunction
|
||||||
|
)
|
||||||
|
|
||||||
|
// NativeResolver 将授权标识解析为随机读取源,生命周期由宿主管理
|
||||||
|
type NativeResolver func(token string) (io.ReaderAt, int64, error)
|
||||||
|
|
||||||
|
// NativeOutputRequest 请求为整次批量转换预留唯一输出入口
|
||||||
|
type NativeOutputRequest struct {
|
||||||
|
Destination any
|
||||||
|
Base, Extension, MIME string
|
||||||
|
Paged bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NativeOutputTarget 保存预留目标,Name为根目录下可整体删除的入口名
|
||||||
|
// Destination交给OpenWriter,分页写入名为Base/页文件名,单文件写入名为Base.Extension
|
||||||
|
type NativeOutputTarget struct {
|
||||||
|
Destination any
|
||||||
|
Base, Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NativeEngine 保存独立会话,不同实例可并行,同一实例的回调不可重入Call或Close
|
||||||
|
// hooks应在首次Call前配置,Resolve返回的资源需保持有效直到相应会话关闭
|
||||||
|
type NativeEngine struct {
|
||||||
|
businessEngine
|
||||||
|
Resolve NativeResolver
|
||||||
|
LoadFont func(context.Context, string) ([]byte, error)
|
||||||
|
// OpenWriter 必须防止覆盖已有文件,成功时Close提交,失败时仅调用可选Abort
|
||||||
|
// 不支持Abort的输出由宿主负责回收,宿主也负责回滚已完成的批量输出
|
||||||
|
OpenWriter func(context.Context, any, string) (io.WriteCloser, error)
|
||||||
|
// ReserveOutput 为单次批量预留整体入口,返回错误时由宿主释放未交付的预留
|
||||||
|
ReserveOutput func(context.Context, NativeOutputRequest) (NativeOutputTarget, error)
|
||||||
|
// CleanupOutput 必须幂等,以兼容前端收到output事件后重复removeEntry
|
||||||
|
CleanupOutput func(context.Context, NativeOutputTarget) error
|
||||||
|
gate chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// nativeReader 在会话当前操作上下文中分块读取,不捕获已结束请求的上下文
|
||||||
|
type nativeReader struct {
|
||||||
|
engine *NativeEngine
|
||||||
|
source io.ReaderAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// nativeCall 保存单次worker适配状态
|
||||||
|
type nativeCall struct {
|
||||||
|
engine *NativeEngine
|
||||||
|
ctx context.Context
|
||||||
|
emit func(any)
|
||||||
|
output io.WriteCloser
|
||||||
|
buffer bytes.Buffer
|
||||||
|
destination any
|
||||||
|
files []any
|
||||||
|
currentName string
|
||||||
|
mime string
|
||||||
|
size int64
|
||||||
|
exporting bool
|
||||||
|
batch bool
|
||||||
|
base string
|
||||||
|
extension string
|
||||||
|
paged bool
|
||||||
|
reserved *NativeOutputTarget
|
||||||
|
committed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// platformValue 仅承载JSON参数、字节与宿主回调,不执行脚本
|
||||||
|
type platformValue struct{ value any }
|
||||||
|
|
||||||
|
// nativeCallback 同步宿主回调
|
||||||
|
type nativeCallback func(...any) (any, error)
|
||||||
|
|
||||||
|
// NewNativeEngine 创建独立业务会话,resolver不接收未经授权的文件路径
|
||||||
|
func NewNativeEngine(resolve NativeResolver) *NativeEngine { return &NativeEngine{Resolve: resolve} }
|
||||||
|
|
||||||
|
// Call 执行worker输入协议,返回ok/data/error,字节保持[]byte,emit不含请求id
|
||||||
|
// 入参: ctx 取消上下文, name 原操作名, args worker参数, emit 同步进度通知
|
||||||
|
// 返回: map[string]any 操作结果,宿主负责将二进制转换为资源句柄
|
||||||
|
// nativeFile只接受授权标识,size以Resolve结果为准,不接受路径字符串或nativeData输出标识
|
||||||
|
// 输出目标为nil时导出返回data.blob,批量返回data.files中的name、mime及file字节
|
||||||
|
// 批量输出到非nil目标时必须同时提供ReserveOutput和CleanupOutput,避免误清理既有文件
|
||||||
|
// 取消为协作式检查,不能抢占宿主阻塞的ReadAt、字体读取或加密库内部计算
|
||||||
|
func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit func(any)) (result map[string]any) {
|
||||||
|
e.once.Do(func() { e.gate = make(chan struct{}, 1); e.resolveSource = e.source })
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case e.gate <- struct{}{}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nativeFailure(ctx.Err())
|
||||||
|
}
|
||||||
|
defer func() { <-e.gate }()
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nativeFailure(err)
|
||||||
|
}
|
||||||
|
if e.closed {
|
||||||
|
return nativeFailure(fmt.Errorf("native engine is closed"))
|
||||||
|
}
|
||||||
|
e.ctx = ctx
|
||||||
|
if e.currentSession != nil {
|
||||||
|
e.currentSession.Renderer.OutputContext = ctx
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if e.currentSession != nil {
|
||||||
|
e.currentSession.Renderer.OutputContext = context.Background()
|
||||||
|
}
|
||||||
|
e.ctx = nil
|
||||||
|
if failure := recover(); failure != nil {
|
||||||
|
if err, ok := failure.(error); ok {
|
||||||
|
result = nativeFailure(err)
|
||||||
|
} else {
|
||||||
|
result = nativeFailure(fmt.Errorf("%v", failure))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
fn := e.businessCommands()[name]
|
||||||
|
if fn == nil {
|
||||||
|
return nativeFailure(fmt.Errorf("unknown operation: %s", name))
|
||||||
|
}
|
||||||
|
call := &nativeCall{engine: e, ctx: ctx, emit: emit}
|
||||||
|
defer func() {
|
||||||
|
if err := call.abort(); err != nil {
|
||||||
|
if result == nil || result["ok"] == true {
|
||||||
|
result = nativeFailure(err)
|
||||||
|
} else {
|
||||||
|
result["error"] = fmt.Sprintf("%v; cleanup: %v", result["error"], err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
prepared, err := call.prepare(name, append([]any(nil), args...))
|
||||||
|
if err != nil {
|
||||||
|
return nativeFailure(err)
|
||||||
|
}
|
||||||
|
values := make([]platformValue, len(prepared))
|
||||||
|
for i, arg := range prepared {
|
||||||
|
values[i] = platformValueOf(arg)
|
||||||
|
}
|
||||||
|
data, err := e.safeCall(fn, values)
|
||||||
|
if err != nil {
|
||||||
|
return nativeFailure(err)
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nativeFailure(err)
|
||||||
|
}
|
||||||
|
if wrapped, ok := data.(platformValue); ok {
|
||||||
|
result = nativePlain(wrapped).(map[string]any)
|
||||||
|
} else {
|
||||||
|
result = map[string]any{"ok": true, "data": nativePlain(data)}
|
||||||
|
}
|
||||||
|
if err := call.finish(name, result); err != nil {
|
||||||
|
return nativeFailure(err)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 释放会话,不关闭宿主持有的ReaderAt资源
|
||||||
|
func (e *NativeEngine) Close() error {
|
||||||
|
e.once.Do(func() { e.gate = make(chan struct{}, 1); e.resolveSource = e.source })
|
||||||
|
e.gate <- struct{}{}
|
||||||
|
defer func() { <-e.gate }()
|
||||||
|
e.closed = true
|
||||||
|
var err error
|
||||||
|
if e.currentSession != nil {
|
||||||
|
err = e.currentSession.Close()
|
||||||
|
}
|
||||||
|
if e.pendingImport != nil {
|
||||||
|
err = errors.Join(err, e.pendingImport.Close())
|
||||||
|
}
|
||||||
|
e.currentSession, e.currentEditor, e.pendingImport, e.copiedObjects, e.copiedStyle = nil, nil, nil, nil, nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// source 读取授权源并以宿主报告的大小为准
|
||||||
|
func (e *NativeEngine) source(ctx context.Context, value any) (io.ReaderAt, int64, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if p, ok := value.(platformValue); ok {
|
||||||
|
value = p.value
|
||||||
|
}
|
||||||
|
if data, ok := value.([]byte); ok {
|
||||||
|
return nativeReader{engine: e, source: bytes.NewReader(data)}, int64(len(data)), nil
|
||||||
|
}
|
||||||
|
descriptor, ok := value.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, 0, fmt.Errorf("expected bytes or nativeFile descriptor")
|
||||||
|
}
|
||||||
|
token, ok := descriptor["nativeFile"].(string)
|
||||||
|
if !ok || token == "" || e.Resolve == nil {
|
||||||
|
return nil, 0, fmt.Errorf("unauthorized native file")
|
||||||
|
}
|
||||||
|
source, size, err := e.Resolve(token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if source == nil || size < 0 {
|
||||||
|
return nil, 0, fmt.Errorf("invalid native file source")
|
||||||
|
}
|
||||||
|
return nativeReader{engine: e, source: source}, size, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadAt 分块传递取消检查,宿主ReadAt自身阻塞时由宿主控制超时
|
||||||
|
func (r nativeReader) ReadAt(data []byte, offset int64) (int, error) {
|
||||||
|
total := 0
|
||||||
|
for len(data) > 0 {
|
||||||
|
if err := r.engine.operationContext().Err(); err != nil {
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
n := min(len(data), 256<<10)
|
||||||
|
got, err := r.source.ReadAt(data[:n], offset)
|
||||||
|
total += got
|
||||||
|
if err != nil {
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
if got != n {
|
||||||
|
return total, io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
data, offset = data[n:], offset+int64(n)
|
||||||
|
}
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readBytes 读取需要完整数据的资源,每块检查取消
|
||||||
|
func (e *NativeEngine) readBytes(ctx context.Context, value any) ([]byte, error) {
|
||||||
|
source, size, err := e.source(ctx, value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var output bytes.Buffer
|
||||||
|
chunk := make([]byte, 256<<10)
|
||||||
|
for offset := int64(0); offset < size; {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
n := int(min(int64(len(chunk)), size-offset))
|
||||||
|
got, err := source.ReadAt(chunk[:n], offset)
|
||||||
|
if got != n {
|
||||||
|
return nil, io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
output.Write(chunk[:got])
|
||||||
|
offset += int64(got)
|
||||||
|
}
|
||||||
|
return output.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// nativePlain 保留字节并转换JSON结构
|
||||||
|
func nativePlain(value any) any {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case platformValue:
|
||||||
|
return nativePlain(v.value)
|
||||||
|
case []byte:
|
||||||
|
return v
|
||||||
|
case map[string]any:
|
||||||
|
result := make(map[string]any, len(v))
|
||||||
|
for k, item := range v {
|
||||||
|
result[k] = nativePlain(item)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
case nil:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := reflect.ValueOf(value)
|
||||||
|
if v.Kind() == reflect.Slice || v.Kind() == reflect.Array {
|
||||||
|
result := make([]any, v.Len())
|
||||||
|
for i := range result {
|
||||||
|
result[i] = nativePlain(v.Index(i).Interface())
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if v.Kind() == reflect.Pointer {
|
||||||
|
if v.IsNil() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nativePlain(v.Elem().Interface())
|
||||||
|
}
|
||||||
|
if v.Kind() == reflect.Struct {
|
||||||
|
data, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
var result any
|
||||||
|
if err := json.Unmarshal(data, &result); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if fields, ok := result.(map[string]any); ok {
|
||||||
|
for i := 0; i < v.NumField(); i++ {
|
||||||
|
field := v.Type().Field(i)
|
||||||
|
if !field.IsExported() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.Split(field.Tag.Get("json"), ",")[0]
|
||||||
|
if name == "-" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if field.Anonymous && name == "" {
|
||||||
|
if embedded, ok := nativePlain(v.Field(i).Interface()).(map[string]any); ok {
|
||||||
|
for key, value := range embedded {
|
||||||
|
if _, exists := fields[key]; exists {
|
||||||
|
fields[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = field.Name
|
||||||
|
}
|
||||||
|
if _, exists := fields[name]; exists {
|
||||||
|
fields[name] = nativePlain(v.Field(i).Interface())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// nativeFailure 转换结构化业务错误
|
||||||
|
func nativeFailure(err error) map[string]any {
|
||||||
|
result := nativePlain(failureResult(err)).(map[string]any)
|
||||||
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
result["canceled"] = true
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// event 发送标准worker进度
|
||||||
|
func (c *nativeCall) event(value map[string]any) {
|
||||||
|
if c.emit != nil {
|
||||||
|
c.emit(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkpoint 检查操作取消
|
||||||
|
func (c *nativeCall) checkpoint(...any) (any, error) { return nil, c.ctx.Err() }
|
||||||
|
|
||||||
|
// progress 创建标准进度回调
|
||||||
|
func (c *nativeCall) progress(kind, stage string, phased bool) nativeCallback {
|
||||||
|
return func(args ...any) (any, error) {
|
||||||
|
if err := c.ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
event := map[string]any{"type": kind}
|
||||||
|
if stage != "" {
|
||||||
|
event["stage"] = stage
|
||||||
|
}
|
||||||
|
if phased {
|
||||||
|
event["phase"], event["completed"], event["total"] = args[0], args[1], args[2]
|
||||||
|
} else {
|
||||||
|
event["completed"], event["total"] = args[0], args[1]
|
||||||
|
}
|
||||||
|
c.event(event)
|
||||||
|
return nil, c.ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readCallback 创建有界随机读取回调
|
||||||
|
func (c *nativeCall) readCallback(value any) (nativeCallback, int64, error) {
|
||||||
|
source, size, err := c.engine.source(c.ctx, value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return func(args ...any) (any, error) {
|
||||||
|
if err := c.ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
offset, n := int64(platformValueOf(args[0]).Float()), platformValueOf(args[1]).Int()
|
||||||
|
if offset < 0 || n < 0 || n > 256<<10 || offset > size || int64(n) > size-offset {
|
||||||
|
return nil, fmt.Errorf("invalid native read range")
|
||||||
|
}
|
||||||
|
data := make([]byte, n)
|
||||||
|
got, err := source.ReadAt(data, offset)
|
||||||
|
if got != n {
|
||||||
|
return nil, io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}, size, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// write 写出数据块
|
||||||
|
func (c *nativeCall) write(args ...any) (any, error) {
|
||||||
|
if err := c.ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
value := platformValueOf(args[0])
|
||||||
|
data, ok := value.value.([]byte)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("expected output bytes")
|
||||||
|
}
|
||||||
|
var writer io.Writer = &c.buffer
|
||||||
|
if c.output != nil {
|
||||||
|
writer = c.output
|
||||||
|
}
|
||||||
|
n, err := writer.Write(data)
|
||||||
|
c.size += int64(n)
|
||||||
|
if err == nil && n != len(data) {
|
||||||
|
err = io.ErrShortWrite
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// open 创建宿主输出,目标只交给授权hook解释
|
||||||
|
func (c *nativeCall) open(name string) error {
|
||||||
|
c.currentName = name
|
||||||
|
c.buffer.Reset()
|
||||||
|
if c.destination == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if c.engine.OpenWriter == nil {
|
||||||
|
return fmt.Errorf("native output resolver is not configured")
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
c.output, err = c.engine.OpenWriter(c.ctx, c.destination, name)
|
||||||
|
if err == nil && c.output == nil {
|
||||||
|
return fmt.Errorf("native output resolver returned nil")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// boundary 处理批量输出文件边界
|
||||||
|
func (c *nativeCall) boundary(args ...any) (any, error) {
|
||||||
|
if err := c.ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
action, name := args[0].(string), args[1].(string)
|
||||||
|
if action == "open" {
|
||||||
|
if c.paged {
|
||||||
|
name = c.base + "/" + name
|
||||||
|
} else {
|
||||||
|
name = c.base + "." + c.extension
|
||||||
|
}
|
||||||
|
return nil, c.open(name)
|
||||||
|
}
|
||||||
|
file := map[string]any{"name": c.currentName, "mime": c.mime}
|
||||||
|
if c.output != nil {
|
||||||
|
if err := c.output.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.output = nil
|
||||||
|
} else {
|
||||||
|
file["file"] = append([]byte(nil), c.buffer.Bytes()...)
|
||||||
|
}
|
||||||
|
c.files = append(c.files, file)
|
||||||
|
c.buffer.Reset()
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// font 按需请求授权字体
|
||||||
|
func (c *nativeCall) font(args ...any) (any, error) {
|
||||||
|
if err := c.ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.engine.LoadFont == nil {
|
||||||
|
return nil, fmt.Errorf("native font resolver is not configured")
|
||||||
|
}
|
||||||
|
return c.engine.LoadFont(c.ctx, args[0].(string))
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileCallback 逐个读取图片或附件
|
||||||
|
func (c *nativeCall) fileCallback(files platformValue) nativeCallback {
|
||||||
|
return func(args ...any) (any, error) {
|
||||||
|
data, err := c.engine.readBytes(c.ctx, files.Index(platformValueOf(args[0]).Int()).value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"bytes": data}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepare 将worker输入转换为共用业务参数
|
||||||
|
func (c *nativeCall) prepare(name string, args []any) ([]any, error) {
|
||||||
|
checkpoint := nativeCallback(c.checkpoint)
|
||||||
|
write := nativeCallback(c.write)
|
||||||
|
switch name {
|
||||||
|
case "ofdgoDetectFormat":
|
||||||
|
if len(args) != 1 {
|
||||||
|
return nil, fmt.Errorf("missing detection input")
|
||||||
|
}
|
||||||
|
read, size, err := c.readCallback(args[0])
|
||||||
|
return []any{read, size, checkpoint}, err
|
||||||
|
case "ofdgoConvertFile":
|
||||||
|
if len(args) != 4 {
|
||||||
|
return nil, fmt.Errorf("missing conversion input")
|
||||||
|
}
|
||||||
|
read, size, err := c.readCallback(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.destination, c.batch = args[3], true
|
||||||
|
options := platformValueOf(args[1])
|
||||||
|
c.base = nativeOutputBase(options.Get("base"))
|
||||||
|
for _, f := range ofdgo.OutputFormats() {
|
||||||
|
if f.Value == options.Get("format").String() {
|
||||||
|
c.mime = f.MIME
|
||||||
|
c.extension, c.paged = f.Extension, f.Paged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.extension == "" {
|
||||||
|
return nil, fmt.Errorf("unsupported conversion format")
|
||||||
|
}
|
||||||
|
if c.destination != nil {
|
||||||
|
if c.engine.ReserveOutput == nil || c.engine.CleanupOutput == nil {
|
||||||
|
return nil, fmt.Errorf("batch destination requires reservation and cleanup hooks")
|
||||||
|
}
|
||||||
|
target, err := c.engine.ReserveOutput(c.ctx, NativeOutputRequest{Destination: c.destination, Base: c.base, Extension: c.extension, MIME: c.mime, Paged: c.paged})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.reserved = &target
|
||||||
|
if target.Destination == nil || target.Base == "" || target.Name == "" || nativeOutputBase(platformValueOf(target.Base)) != target.Base {
|
||||||
|
return nil, fmt.Errorf("invalid reserved output target")
|
||||||
|
}
|
||||||
|
c.destination, c.base = target.Destination, target.Base
|
||||||
|
c.event(map[string]any{"type": "output", "name": target.Name})
|
||||||
|
}
|
||||||
|
return []any{read, size, args[1], write, c.progress("conversion", "", true), nativeCallback(c.boundary), checkpoint, args[2], nativeCallback(c.font)}, nil
|
||||||
|
case "ofdgoPackFiles":
|
||||||
|
if len(args) != 3 {
|
||||||
|
return nil, fmt.Errorf("missing archive input")
|
||||||
|
}
|
||||||
|
entries := platformValueOf(args[0])
|
||||||
|
metadata := make([]any, entries.Length())
|
||||||
|
readers := make([]nativeCallback, entries.Length())
|
||||||
|
for i := range readers {
|
||||||
|
entry := entries.Index(i)
|
||||||
|
read, size, err := c.readCallback(entry.Get("file").value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
readers[i] = read
|
||||||
|
metadata[i] = map[string]any{"name": entry.Get("name").String(), "size": size}
|
||||||
|
}
|
||||||
|
c.destination, c.exporting = args[1], true
|
||||||
|
if err := c.open(""); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
progress := nativeCallback(func(values ...any) (any, error) {
|
||||||
|
c.event(map[string]any{"type": "conversion", "phase": "pack", "completed": values[0], "total": values[1]})
|
||||||
|
return nil, c.ctx.Err()
|
||||||
|
})
|
||||||
|
return []any{metadata, nativeCallback(func(values ...any) (any, error) { return readers[platformValueOf(values[0]).Int()], nil }), write, progress, checkpoint, args[2]}, nil
|
||||||
|
case "ofdgoChangeAttachment":
|
||||||
|
if len(args) > 3 && (args[0] == "add" || args[0] == "replace") {
|
||||||
|
args[3] = c.fileCallback(platformValueOf(args[3]))
|
||||||
|
}
|
||||||
|
case "ofdgoChangeResource":
|
||||||
|
if len(args) > 4 && (args[0] == "add" || args[0] == "replace") {
|
||||||
|
data, err := c.engine.readBytes(c.ctx, args[4])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
args[4] = data
|
||||||
|
}
|
||||||
|
case "ofdgoImportImages":
|
||||||
|
if len(args) != 5 {
|
||||||
|
return nil, fmt.Errorf("missing image import input")
|
||||||
|
}
|
||||||
|
files := platformValueOf(args[0])
|
||||||
|
names := make([]any, files.Length())
|
||||||
|
for i := range names {
|
||||||
|
names[i] = files.Index(i).Get("name").String()
|
||||||
|
}
|
||||||
|
args[0] = names
|
||||||
|
args = append(args, c.fileCallback(files))
|
||||||
|
}
|
||||||
|
switch name {
|
||||||
|
case "ofdgoResources":
|
||||||
|
return append(args, checkpoint), nil
|
||||||
|
case "ofdgoImportPages", "ofdgoImportDocuments", "ofdgoImportImages":
|
||||||
|
return append(args, c.progress("import", "", true)), nil
|
||||||
|
case "ofdgoConvertPDF":
|
||||||
|
return append(args, c.progress("conversion", "", true), nativeCallback(c.font)), nil
|
||||||
|
case "ofdgoExportImages", "ofdgoExportPage", "ofdgoExportDocument", "ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion", "ofdgoSaveDocument", "ofdgoSaveSigned":
|
||||||
|
if len(args) == 0 {
|
||||||
|
return nil, fmt.Errorf("missing output arguments")
|
||||||
|
}
|
||||||
|
c.destination, c.exporting = args[len(args)-1], true
|
||||||
|
args = args[:len(args)-1]
|
||||||
|
if err := c.open(""); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
saving := name == "ofdgoSaveDocument" || name == "ofdgoSaveSigned"
|
||||||
|
var indices, compression any
|
||||||
|
if saving {
|
||||||
|
if name != "ofdgoSaveSigned" && len(args) > 0 {
|
||||||
|
if m, ok := args[len(args)-1].(map[string]any); ok && m["mode"] != nil {
|
||||||
|
compression = m
|
||||||
|
args = args[:len(args)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(args) == 0 {
|
||||||
|
return nil, fmt.Errorf("missing save page selection")
|
||||||
|
}
|
||||||
|
indices, args = args[0], args[1:]
|
||||||
|
}
|
||||||
|
args = append(args, write)
|
||||||
|
if name == "ofdgoExportDocument" {
|
||||||
|
args = append(args, c.progress("export", "pages", false))
|
||||||
|
}
|
||||||
|
if name == "ofdgoExportVersion" {
|
||||||
|
args = append(args, c.progress("export", "prepare", true), checkpoint)
|
||||||
|
}
|
||||||
|
if saving {
|
||||||
|
args = append(args, c.progress("export", "prepare", true))
|
||||||
|
if compression != nil {
|
||||||
|
args = append(args, indices, compression, checkpoint)
|
||||||
|
} else if indices != nil {
|
||||||
|
args = append(args, indices)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, arg := range args {
|
||||||
|
if m, ok := arg.(map[string]any); ok && m["mode"] != nil {
|
||||||
|
args = append(args, checkpoint)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// finish 完成输出并保留原生字节
|
||||||
|
func (c *nativeCall) finish(name string, result map[string]any) error {
|
||||||
|
if !c.exporting && !c.batch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, ok := result["data"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("invalid output result")
|
||||||
|
}
|
||||||
|
if c.batch {
|
||||||
|
if data["unchanged"] == true && c.size == 0 {
|
||||||
|
c.files = []any{}
|
||||||
|
if err := c.abort(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data["files"] = c.files
|
||||||
|
} else if c.output != nil {
|
||||||
|
if err := c.output.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.output = nil
|
||||||
|
} else {
|
||||||
|
data["blob"] = append([]byte(nil), c.buffer.Bytes()...)
|
||||||
|
}
|
||||||
|
data["size"] = c.size
|
||||||
|
c.committed = true
|
||||||
|
if c.exporting && name != "ofdgoExportMedia" && name != "ofdgoPackFiles" {
|
||||||
|
c.event(map[string]any{"type": "export", "stage": "save"})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// abort 失败时释放尚未提交的宿主输出,支持可选Abort回滚
|
||||||
|
func (c *nativeCall) abort() error {
|
||||||
|
var err error
|
||||||
|
if c.output != nil {
|
||||||
|
if output, ok := c.output.(interface{ Abort() error }); ok {
|
||||||
|
err = output.Abort()
|
||||||
|
}
|
||||||
|
c.output = nil
|
||||||
|
}
|
||||||
|
if c.reserved != nil && !c.committed {
|
||||||
|
target := *c.reserved
|
||||||
|
c.reserved = nil
|
||||||
|
err = errors.Join(err, c.engine.CleanupOutput(context.WithoutCancel(c.ctx), target))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// nativeOutputBase 清理worker输出文件名,冲突后缀由宿主排他创建决定
|
||||||
|
func nativeOutputBase(value platformValue) string {
|
||||||
|
base := "document"
|
||||||
|
if value.Type() == platformTypeString {
|
||||||
|
base = value.String()
|
||||||
|
}
|
||||||
|
base = strings.Map(func(r rune) rune {
|
||||||
|
if r < 32 || strings.ContainsRune("<>:\"/\\|?*", r) {
|
||||||
|
return '_'
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, base)
|
||||||
|
base = strings.TrimRight(base, ". ")
|
||||||
|
if base == "" {
|
||||||
|
return "document"
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceFromValue 读取宿主授权的随机读取源
|
||||||
|
func (e *businessEngine) sourceFromValue(value platformValue) (io.ReaderAt, int64, error) {
|
||||||
|
return e.resolveSource(e.operationContext(), value.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// platformValueOf 包装参数
|
||||||
|
func platformValueOf(v any) platformValue {
|
||||||
|
if p, ok := v.(platformValue); ok {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return platformValue{v}
|
||||||
|
}
|
||||||
|
|
||||||
|
// platformNull 返回空参数
|
||||||
|
func platformNull() platformValue { return platformValue{} }
|
||||||
|
|
||||||
|
// Type 返回协议参数类型
|
||||||
|
func (v platformValue) Type() int {
|
||||||
|
switch v.value.(type) {
|
||||||
|
case nil:
|
||||||
|
return platformTypeUndefined
|
||||||
|
case string:
|
||||||
|
return platformTypeString
|
||||||
|
case bool:
|
||||||
|
return platformTypeBoolean
|
||||||
|
case nativeCallback:
|
||||||
|
return platformTypeFunction
|
||||||
|
case float64, float32, int, int64, uint64, json.Number:
|
||||||
|
return platformTypeNumber
|
||||||
|
default:
|
||||||
|
return platformTypeObject
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsNull 检查空参数
|
||||||
|
func (v platformValue) IsNull() bool { return v.value == nil }
|
||||||
|
|
||||||
|
// IsUndefined 检查缺省参数
|
||||||
|
func (v platformValue) IsUndefined() bool { return v.value == nil }
|
||||||
|
|
||||||
|
// String 读取字符串
|
||||||
|
func (v platformValue) String() string {
|
||||||
|
if v.value == nil {
|
||||||
|
return "<undefined>"
|
||||||
|
}
|
||||||
|
return fmt.Sprint(v.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bool 读取布尔参数
|
||||||
|
func (v platformValue) Bool() bool {
|
||||||
|
b, ok := v.value.(bool)
|
||||||
|
if !ok {
|
||||||
|
panic("expected boolean")
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Float 读取数值参数
|
||||||
|
func (v platformValue) Float() float64 {
|
||||||
|
switch n := v.value.(type) {
|
||||||
|
case float64:
|
||||||
|
return n
|
||||||
|
case float32:
|
||||||
|
return float64(n)
|
||||||
|
case int:
|
||||||
|
return float64(n)
|
||||||
|
case int64:
|
||||||
|
return float64(n)
|
||||||
|
case uint64:
|
||||||
|
return float64(n)
|
||||||
|
case json.Number:
|
||||||
|
f, err := n.Float64()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
default:
|
||||||
|
panic("expected number")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Int 读取整数参数
|
||||||
|
func (v platformValue) Int() int { return int(v.Float()) }
|
||||||
|
|
||||||
|
// Get 读取对象字段
|
||||||
|
func (v platformValue) Get(key string) platformValue {
|
||||||
|
if m, ok := v.value.(map[string]any); ok {
|
||||||
|
return platformValueOf(m[key])
|
||||||
|
}
|
||||||
|
if key == "length" || key == "byteLength" {
|
||||||
|
return platformValueOf(v.Length())
|
||||||
|
}
|
||||||
|
return platformNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Length 读取数组长度
|
||||||
|
func (v platformValue) Length() int {
|
||||||
|
if v.value == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return reflect.ValueOf(v.value).Len()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index 读取数组元素
|
||||||
|
func (v platformValue) Index(i int) platformValue {
|
||||||
|
return platformValueOf(reflect.ValueOf(v.value).Index(i).Interface())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoke 调用宿主函数
|
||||||
|
func (v platformValue) Invoke(args ...any) platformValue {
|
||||||
|
result, err := v.value.(nativeCallback)(args...)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return platformValueOf(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// platformCopyBytes 复制读取块
|
||||||
|
func platformCopyBytes(dst []byte, src platformValue) int {
|
||||||
|
return copy(dst, src.value.([]byte))
|
||||||
|
}
|
||||||
|
|
||||||
|
// platformJSON 编码小参数
|
||||||
|
func platformJSON(v platformValue) string {
|
||||||
|
data, err := json.Marshal(v.value)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// platformParseJSON 解析小参数
|
||||||
|
func platformParseJSON(data []byte) map[string]any {
|
||||||
|
var v map[string]any
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// bytesToJS 保留原生二进制数据
|
||||||
|
func bytesToJS(data []byte) platformValue { return platformValueOf(data) }
|
||||||
|
|
||||||
|
// bytesFromJS 读取字节或授权资源
|
||||||
|
func (e *businessEngine) bytesFromJS(v platformValue) ([]byte, error) {
|
||||||
|
if data, ok := v.value.([]byte); ok {
|
||||||
|
return append([]byte(nil), data...), nil
|
||||||
|
}
|
||||||
|
source, size, err := e.sourceFromValue(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return io.ReadAll(io.NewSectionReader(source, 0, size))
|
||||||
|
}
|
||||||
|
|
||||||
|
// successResult 保留二进制返回结构
|
||||||
|
func successResult(data any) platformValue {
|
||||||
|
return platformValueOf(map[string]any{"ok": true, "data": data})
|
||||||
|
}
|
||||||
|
|
||||||
|
// awaitExport 执行可取消宿主回调
|
||||||
|
func awaitExport(fn platformValue, args ...any) error {
|
||||||
|
_, err := fn.value.(nativeCallback)(args...)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConversionFont 读取宿主授权字体
|
||||||
|
func (e *businessEngine) loadConversionFont(fn platformValue, name string) ([]byte, error) {
|
||||||
|
result, err := fn.value.(nativeCallback)(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return e.bytesFromJS(platformValueOf(result))
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"syscall/js"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
platformTypeNumber = js.TypeNumber
|
||||||
|
platformTypeString = js.TypeString
|
||||||
|
platformTypeBoolean = js.TypeBoolean
|
||||||
|
platformTypeObject = js.TypeObject
|
||||||
|
platformTypeFunction = js.TypeFunction
|
||||||
|
)
|
||||||
|
|
||||||
|
// platformValue 浏览器参数值
|
||||||
|
type platformValue = js.Value
|
||||||
|
|
||||||
|
// RunWASM 注册浏览器WASM接口并阻塞运行
|
||||||
|
func RunWASM() {
|
||||||
|
e := &businessEngine{}
|
||||||
|
asynchronous := map[string]bool{
|
||||||
|
"ofdgoDetectFormat": true,
|
||||||
|
"ofdgoExportVersion": true,
|
||||||
|
"ofdgoConvertPDF": true,
|
||||||
|
"ofdgoConvertFile": true,
|
||||||
|
"ofdgoPackFiles": true,
|
||||||
|
"ofdgoExportPage": true,
|
||||||
|
"ofdgoExportDocument": true,
|
||||||
|
"ofdgoExportAttachment": true,
|
||||||
|
"ofdgoExportMedia": true,
|
||||||
|
"ofdgoResources": true,
|
||||||
|
"ofdgoExportResource": true,
|
||||||
|
"ofdgoExportImages": true,
|
||||||
|
"ofdgoImportImages": true,
|
||||||
|
"ofdgoImportPages": true,
|
||||||
|
"ofdgoImportDocuments": true,
|
||||||
|
"ofdgoSaveDocument": true,
|
||||||
|
"ofdgoSetEncryption": true,
|
||||||
|
"ofdgoSaveSigned": true,
|
||||||
|
}
|
||||||
|
for name, callback := range e.businessCommands() {
|
||||||
|
if asynchronous[name] {
|
||||||
|
registerAsyncCallback(name, callback)
|
||||||
|
} else {
|
||||||
|
registerCallback(name, callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// awaitExport 等待浏览器处理导出检查点
|
||||||
|
// 入参: fn 浏览器回调, args 回调参数
|
||||||
|
// 返回: error 写入错误或取消原因
|
||||||
|
func awaitExport(fn js.Value, args ...any) error {
|
||||||
|
done := make(chan error)
|
||||||
|
callback := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
var err error
|
||||||
|
if args[1].Bool() {
|
||||||
|
err = context.Canceled
|
||||||
|
} else if message := args[0].String(); message != "" {
|
||||||
|
err = fmt.Errorf("%s", message)
|
||||||
|
}
|
||||||
|
done <- err
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
defer callback.Release()
|
||||||
|
fn.Invoke(append(args, callback)...)
|
||||||
|
return <-done
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerCallback 注册浏览器回调函数
|
||||||
|
// 入参: name 回调名称, fn 回调函数
|
||||||
|
func registerCallback(name string, fn func([]js.Value) (any, error)) {
|
||||||
|
cb := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
return callbackResult(fn, args)
|
||||||
|
})
|
||||||
|
js.Global().Set(name, cb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerAsyncCallback 注册异步回调,为浏览器处理数据和取消请求让出事件循环
|
||||||
|
// 入参: name 回调名称, fn 回调函数
|
||||||
|
func registerAsyncCallback(name string, fn func([]js.Value) (any, error)) {
|
||||||
|
cb := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
executor := js.FuncOf(func(this js.Value, callbacks []js.Value) any {
|
||||||
|
resolve := callbacks[0]
|
||||||
|
go func() {
|
||||||
|
resolve.Invoke(callbackResult(fn, args))
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
defer executor.Release()
|
||||||
|
return js.Global().Get("Promise").New(executor)
|
||||||
|
})
|
||||||
|
js.Global().Set(name, cb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// callbackResult 转换浏览器接口结果
|
||||||
|
// 入参: fn 回调函数, args 回调参数
|
||||||
|
// 返回: any 浏览器结果
|
||||||
|
func callbackResult(fn func([]js.Value) (any, error), args []js.Value) any {
|
||||||
|
data, err := (&businessEngine{}).safeCall(fn, args)
|
||||||
|
if err != nil {
|
||||||
|
result := failureResult(err)
|
||||||
|
return (&businessEngine{}).encodeResult(result)
|
||||||
|
}
|
||||||
|
if result, ok := data.(js.Value); ok {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return (&businessEngine{}).encodeResult(apiResult{OK: true, Data: data})
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConversionFont 按需读取浏览器已授权字体,不预读系统字体目录
|
||||||
|
// 入参: load 浏览器读取函数, name 字体文件名
|
||||||
|
// 返回: []byte 字体内容, error 读取错误或取消原因
|
||||||
|
func (e *businessEngine) loadConversionFont(load js.Value, name string) ([]byte, error) {
|
||||||
|
done := make(chan error, 1)
|
||||||
|
var data []byte
|
||||||
|
callback := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
var err error
|
||||||
|
if args[2].Bool() {
|
||||||
|
err = context.Canceled
|
||||||
|
} else if message := args[1].String(); message != "" {
|
||||||
|
err = fmt.Errorf("%s", message)
|
||||||
|
} else {
|
||||||
|
data, err = e.bytesFromJS(args[0])
|
||||||
|
}
|
||||||
|
done <- err
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
defer callback.Release()
|
||||||
|
load.Invoke(name, callback)
|
||||||
|
err := <-done
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// successResult 创建成功接口结果
|
||||||
|
// 入参: data 返回数据
|
||||||
|
// 返回: js.Value 接口结果
|
||||||
|
func successResult(data any) js.Value {
|
||||||
|
result := js.Global().Get("Object").New()
|
||||||
|
result.Set("ok", true)
|
||||||
|
result.Set("data", data)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// bytesToJS 将字节数据转换为Uint8Array
|
||||||
|
// 入参: data 字节数据
|
||||||
|
// 返回: js.Value Uint8Array对象
|
||||||
|
func bytesToJS(data []byte) js.Value {
|
||||||
|
value := js.Global().Get("Uint8Array").New(len(data))
|
||||||
|
js.CopyBytesToJS(value, data)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// bytesFromJS 从浏览器值读取二进制数据
|
||||||
|
// 入参: value 浏览器值
|
||||||
|
// 返回: []byte 二进制数据, error 错误信息
|
||||||
|
func (e *businessEngine) bytesFromJS(value js.Value) ([]byte, error) {
|
||||||
|
if value.IsUndefined() || value.IsNull() {
|
||||||
|
return nil, fmt.Errorf("missing binary data")
|
||||||
|
}
|
||||||
|
uint8Array := js.Global().Get("Uint8Array")
|
||||||
|
arrayBuffer := js.Global().Get("ArrayBuffer")
|
||||||
|
if value.InstanceOf(arrayBuffer) {
|
||||||
|
value = uint8Array.New(value)
|
||||||
|
}
|
||||||
|
if !value.InstanceOf(uint8Array) {
|
||||||
|
return nil, fmt.Errorf("binary data must be Uint8Array or ArrayBuffer")
|
||||||
|
}
|
||||||
|
data := make([]byte, value.Get("byteLength").Int())
|
||||||
|
if n := js.CopyBytesToGo(data, value); n != len(data) {
|
||||||
|
return nil, fmt.Errorf("copied %d of %d bytes", n, len(data))
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// platformValueOf 转换浏览器参数
|
||||||
|
func platformValueOf(v any) platformValue { return js.ValueOf(v) }
|
||||||
|
|
||||||
|
// platformNull 返回空参数
|
||||||
|
func platformNull() platformValue { return js.Null() }
|
||||||
|
|
||||||
|
// platformCopyBytes 复制浏览器字节
|
||||||
|
func platformCopyBytes(dst []byte, src platformValue) int { return js.CopyBytesToGo(dst, src) }
|
||||||
|
|
||||||
|
// platformJSON 编码小参数
|
||||||
|
func platformJSON(v platformValue) string {
|
||||||
|
return js.Global().Get("JSON").Call("stringify", v).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// platformParseJSON 解析结构参数
|
||||||
|
func platformParseJSON(data []byte) map[string]any {
|
||||||
|
var result map[string]any
|
||||||
|
if err := json.Unmarshal(data, &result); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceFromValue 读取浏览器二进制源
|
||||||
|
func (e *businessEngine) sourceFromValue(value platformValue) (io.ReaderAt, int64, error) {
|
||||||
|
data, err := e.bytesFromJS(value)
|
||||||
|
return bytes.NewReader(data), int64(len(data)), err
|
||||||
|
}
|
||||||
@@ -0,0 +1,685 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
//go:build windows && amd64
|
||||||
|
|
||||||
|
package webview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/tdewolff/canvas"
|
||||||
|
"github.com/tdewolff/canvas/renderers/rasterizer"
|
||||||
|
"github.com/xiaoqidun/guigo"
|
||||||
|
"github.com/xiaoqidun/ofdgo"
|
||||||
|
webuiassets "github.com/xiaoqidun/ofdgo/assets/webui"
|
||||||
|
"github.com/xiaoqidun/ofdgo/internal/webui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// applicationID 标识原生应用,与版本及可执行文件路径无关
|
||||||
|
const applicationID = "me.aite.ofdgo"
|
||||||
|
|
||||||
|
// Host 连接桌面系统能力和共用文档业务
|
||||||
|
type Host struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
window *guigo.Window
|
||||||
|
files *fileStore
|
||||||
|
engines map[string]*webui.NativeEngine
|
||||||
|
cancels map[string]context.CancelFunc
|
||||||
|
fontOnce sync.Once
|
||||||
|
fonts []map[string]any
|
||||||
|
fontFiles map[string]string
|
||||||
|
fontError error
|
||||||
|
pendingPaths []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// resourceReader 保持资源独立的读取位置,文件生命周期由窗口管理
|
||||||
|
type resourceReader struct {
|
||||||
|
*io.SectionReader
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run 启动共用WebUI的原生入口
|
||||||
|
// 入参: ctx 运行上下文, paths 初始文件路径, dataDir 浏览器数据目录
|
||||||
|
// 返回: error 启动或运行错误
|
||||||
|
func Run(ctx context.Context, paths []string, dataDir string) error {
|
||||||
|
options, err := windowOptions(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
files, err := newFileStore()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h := &Host{files: files, engines: make(map[string]*webui.NativeEngine), cancels: make(map[string]context.CancelFunc), fontFiles: make(map[string]string), pendingPaths: append([]string(nil), paths...)}
|
||||||
|
defer h.close()
|
||||||
|
bindings := make(map[string]guigo.HandlerFunc)
|
||||||
|
for _, name := range []string{"engine.open", "engine.close", "engine.call", "engine.cancel", "open", "save", "directory", "file", "release", "child", "remove", "write.begin", "write.data", "write.commit", "write.abort", "fonts", "ready", "title"} {
|
||||||
|
bindings["ofdgo.host."+name] = func(ctx context.Context, raw json.RawMessage) (any, error) { return h.call(ctx, name, raw) }
|
||||||
|
}
|
||||||
|
static := http.FileServerFS(webuiassets.NativeFS)
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/__native/") {
|
||||||
|
files.serve(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(r.URL.Path, ".wasm") || strings.HasSuffix(r.URL.Path, "wasm_exec.js") || strings.HasSuffix(r.URL.Path, "ofdgo_work.js") {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
static.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
options.Handler = handler
|
||||||
|
options.ResourceHandler = h.resource
|
||||||
|
options.Bindings = bindings
|
||||||
|
options.OnReady = func(window *guigo.Window) {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.window = window
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
options.OnClose = func() {
|
||||||
|
h.mu.Lock()
|
||||||
|
for _, cancel := range h.cancels {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
options.OnDrop = h.openPaths
|
||||||
|
options.OnError = func(err error) {
|
||||||
|
h.emit("ofdgo.error", err.Error())
|
||||||
|
}
|
||||||
|
return guigo.Run(ctx, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 结束本次资源读取
|
||||||
|
func (r *resourceReader) Close() error { return nil }
|
||||||
|
|
||||||
|
// resource 将大文件直接交给宿主资源流
|
||||||
|
func (h *Host) resource(r *http.Request) (*guigo.Resource, error) {
|
||||||
|
if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, "/__native/data/") || r.Header.Get("Range") != "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
id := strings.TrimPrefix(r.URL.Path, "/__native/data/")
|
||||||
|
reader, size, err := h.files.resolve(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
header := make([]byte, min(size, 512))
|
||||||
|
_, err = reader.ReadAt(header, 0)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &guigo.Resource{
|
||||||
|
Body: &resourceReader{io.NewSectionReader(reader, 0, size)},
|
||||||
|
Header: http.Header{"Content-Type": {http.DetectContentType(header)}, "Content-Length": {strconv.FormatInt(size, 10)}, "Cache-Control": {"no-store"}},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// emit 发送当前窗口事件
|
||||||
|
func (h *Host) emit(name string, value any) {
|
||||||
|
h.mu.Lock()
|
||||||
|
w := h.window
|
||||||
|
h.mu.Unlock()
|
||||||
|
if w != nil {
|
||||||
|
_ = w.Emit(name, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openPaths 将操作系统提供的文件登记后交给界面
|
||||||
|
func (h *Host) openPaths(names []string) {
|
||||||
|
files := make([]any, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
handle, err := h.files.add(name, false)
|
||||||
|
if err != nil {
|
||||||
|
h.emit("ofdgo.error", err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
file, err := h.files.file(handle["nativeHandle"].(string))
|
||||||
|
if err == nil {
|
||||||
|
files = append(files, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.emit("ofdgo.files", files)
|
||||||
|
}
|
||||||
|
|
||||||
|
// call 执行宿主协议并保留业务结果结构
|
||||||
|
func (h *Host) call(ctx context.Context, name string, raw json.RawMessage) (any, error) {
|
||||||
|
var args map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
text := func(key string) string { value, _ := args[key].(string); return value }
|
||||||
|
flag := func(key string) bool { value, _ := args[key].(bool); return value }
|
||||||
|
switch name {
|
||||||
|
case "engine.open":
|
||||||
|
engine := webui.NewNativeEngine(h.files.resolve)
|
||||||
|
engine.OpenWriter = h.openWriter
|
||||||
|
engine.ReserveOutput = h.reserveOutput
|
||||||
|
engine.CleanupOutput = h.cleanupOutput
|
||||||
|
engine.LoadFont = h.loadFont
|
||||||
|
id := token()
|
||||||
|
h.mu.Lock()
|
||||||
|
h.engines[id] = engine
|
||||||
|
h.mu.Unlock()
|
||||||
|
return map[string]any{"engine": id}, nil
|
||||||
|
case "engine.close":
|
||||||
|
id := text("engine")
|
||||||
|
h.mu.Lock()
|
||||||
|
engine := h.engines[id]
|
||||||
|
delete(h.engines, id)
|
||||||
|
for key, cancel := range h.cancels {
|
||||||
|
if strings.HasPrefix(key, id+"/") {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
if engine != nil {
|
||||||
|
return nil, engine.Close()
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
case "engine.cancel":
|
||||||
|
key := text("engine") + "/" + fmt.Sprint(args["id"])
|
||||||
|
h.mu.Lock()
|
||||||
|
cancel := h.cancels[key]
|
||||||
|
h.mu.Unlock()
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
case "engine.call":
|
||||||
|
id := text("engine")
|
||||||
|
key := id + "/" + fmt.Sprint(args["id"])
|
||||||
|
operation, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
h.mu.Lock()
|
||||||
|
engine := h.engines[id]
|
||||||
|
if _, exists := h.cancels[key]; exists {
|
||||||
|
h.mu.Unlock()
|
||||||
|
return nil, fmt.Errorf("请求重复")
|
||||||
|
}
|
||||||
|
h.cancels[key] = cancel
|
||||||
|
h.mu.Unlock()
|
||||||
|
defer func() { h.mu.Lock(); delete(h.cancels, key); h.mu.Unlock() }()
|
||||||
|
if engine == nil {
|
||||||
|
return nil, fmt.Errorf("会话已关闭")
|
||||||
|
}
|
||||||
|
values, _ := args["args"].([]any)
|
||||||
|
for i, value := range values {
|
||||||
|
var err error
|
||||||
|
values[i], err = nativeBytes(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := engine.Call(operation, text("name"), values, func(event any) {
|
||||||
|
if value, ok := event.(map[string]any); ok {
|
||||||
|
value["id"] = args["id"]
|
||||||
|
h.emit("ofdgo.engine."+id, value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return h.encode(result), nil
|
||||||
|
case "open", "save", "directory":
|
||||||
|
h.mu.Lock()
|
||||||
|
window := h.window
|
||||||
|
h.mu.Unlock()
|
||||||
|
if window == nil {
|
||||||
|
return nil, fmt.Errorf("窗口尚未就绪")
|
||||||
|
}
|
||||||
|
options := guigo.DialogOptions{DefaultName: text("suggestedName"), Filters: dialogFilters(args)}
|
||||||
|
if name == "open" {
|
||||||
|
var names []string
|
||||||
|
var err error
|
||||||
|
if flag("directory") {
|
||||||
|
var directory string
|
||||||
|
directory, err = window.OpenDirectory(options)
|
||||||
|
if err == nil {
|
||||||
|
err = filepath.WalkDir(directory, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if entry.Type().IsRegular() {
|
||||||
|
names = append(names, path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if flag("multiple") {
|
||||||
|
names, err = window.OpenFiles(options)
|
||||||
|
} else {
|
||||||
|
var file string
|
||||||
|
file, err = window.OpenFile(options)
|
||||||
|
if err == nil {
|
||||||
|
names = []string{file}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, dialogError(err)
|
||||||
|
}
|
||||||
|
result := make([]any, 0, len(names))
|
||||||
|
for _, file := range names {
|
||||||
|
handle, err := h.files.add(file, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
value, err := h.files.file(handle["nativeHandle"].(string))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if name == "save" {
|
||||||
|
file, err := window.SaveFile(options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, dialogError(err)
|
||||||
|
}
|
||||||
|
return h.files.save(file), nil
|
||||||
|
}
|
||||||
|
file, err := window.OpenDirectory(options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, dialogError(err)
|
||||||
|
}
|
||||||
|
return h.files.add(file, true)
|
||||||
|
case "file":
|
||||||
|
return h.files.file(text("handle"))
|
||||||
|
case "release":
|
||||||
|
h.files.release(text("file"))
|
||||||
|
return nil, nil
|
||||||
|
case "child":
|
||||||
|
value, err := h.files.child(text("handle"), text("name"), flag("directory"), flag("create"))
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, &guigo.BridgeError{Name: "NotFoundError", Message: err.Error()}
|
||||||
|
}
|
||||||
|
return value, err
|
||||||
|
case "remove":
|
||||||
|
return nil, h.files.remove(text("handle"), text("name"), flag("recursive"))
|
||||||
|
case "write.begin":
|
||||||
|
writer, err := h.files.begin(text("handle"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"writer": writer.id}, nil
|
||||||
|
case "write.data", "write.commit", "write.abort":
|
||||||
|
id := text("writer")
|
||||||
|
h.files.mu.Lock()
|
||||||
|
writer := h.files.writers[id]
|
||||||
|
h.files.mu.Unlock()
|
||||||
|
if writer == nil {
|
||||||
|
return nil, fmt.Errorf("写入已结束")
|
||||||
|
}
|
||||||
|
if name == "write.commit" {
|
||||||
|
return nil, writer.Close()
|
||||||
|
}
|
||||||
|
if name == "write.abort" {
|
||||||
|
return nil, writer.Abort()
|
||||||
|
}
|
||||||
|
descriptor, _ := args["data"].(map[string]any)
|
||||||
|
if value, err := nativeBytes(descriptor); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if data, ok := value.([]byte); ok {
|
||||||
|
_, err = writer.Write(data)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
key, _ := descriptor["nativeFile"].(string)
|
||||||
|
source, size, err := h.files.resolve(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(writer, io.NewSectionReader(source, 0, size))
|
||||||
|
return nil, err
|
||||||
|
case "fonts":
|
||||||
|
h.fontOnce.Do(h.readFonts)
|
||||||
|
return h.fonts, h.fontError
|
||||||
|
case "title":
|
||||||
|
h.mu.Lock()
|
||||||
|
window := h.window
|
||||||
|
h.mu.Unlock()
|
||||||
|
if window == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, window.SetTitle(text("title"))
|
||||||
|
case "ready":
|
||||||
|
h.mu.Lock()
|
||||||
|
paths := h.pendingPaths
|
||||||
|
h.pendingPaths = nil
|
||||||
|
h.mu.Unlock()
|
||||||
|
if len(paths) != 0 {
|
||||||
|
h.openPaths(paths)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("未知宿主操作")
|
||||||
|
}
|
||||||
|
|
||||||
|
// nativeBytes 还原仅在消息内传递的小型二进制参数
|
||||||
|
func nativeBytes(value any) (any, error) {
|
||||||
|
switch value := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
if raw, exists := value["nativeBytes"]; exists {
|
||||||
|
items, ok := raw.([]any)
|
||||||
|
if !ok || len(value) != 1 || len(items) > 1<<20 {
|
||||||
|
return nil, fmt.Errorf("二进制参数无效")
|
||||||
|
}
|
||||||
|
data := make([]byte, len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
number, ok := item.(float64)
|
||||||
|
if !ok || number < 0 || number > 255 || float64(byte(number)) != number {
|
||||||
|
return nil, fmt.Errorf("二进制参数无效")
|
||||||
|
}
|
||||||
|
data[i] = byte(number)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
for key, item := range value {
|
||||||
|
decoded, err := nativeBytes(item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
value[key] = decoded
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
case []any:
|
||||||
|
for i, item := range value {
|
||||||
|
decoded, err := nativeBytes(item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
value[i] = decoded
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
default:
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialogFilters 将共用界面的文件类型映射到系统对话框
|
||||||
|
func dialogFilters(args map[string]any) []guigo.FileFilter {
|
||||||
|
patterns := func(values []string) string {
|
||||||
|
var result []string
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if !strings.HasPrefix(value, ".") || strings.ContainsAny(value, "/\\;*?\x00") || seen[value] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = true
|
||||||
|
result = append(result, "*"+value)
|
||||||
|
}
|
||||||
|
return strings.Join(result, ";")
|
||||||
|
}
|
||||||
|
var filters []guigo.FileFilter
|
||||||
|
if accept, ok := args["accept"].(string); ok {
|
||||||
|
var extensions []string
|
||||||
|
for _, value := range strings.Split(accept, ",") {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if strings.HasPrefix(value, ".") {
|
||||||
|
extensions = append(extensions, value)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch value {
|
||||||
|
case "image/*":
|
||||||
|
extensions = append(extensions, ".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".gif", ".webp", ".jb2", ".jbig2")
|
||||||
|
case "font/*":
|
||||||
|
extensions = append(extensions, ".ttf", ".otf", ".ttc", ".otc")
|
||||||
|
default:
|
||||||
|
values, err := mime.ExtensionsByType(value)
|
||||||
|
if err == nil {
|
||||||
|
sort.Strings(values)
|
||||||
|
extensions = append(extensions, values...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pattern := patterns(extensions); pattern != "" {
|
||||||
|
filters = append(filters, guigo.FileFilter{Name: "支持的文件", Pattern: pattern})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
types, _ := args["types"].([]any)
|
||||||
|
for _, item := range types {
|
||||||
|
value, _ := item.(map[string]any)
|
||||||
|
accept, _ := value["accept"].(map[string]any)
|
||||||
|
var extensions []string
|
||||||
|
var names []string
|
||||||
|
for name := range accept {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
for _, name := range names {
|
||||||
|
values := accept[name]
|
||||||
|
list, _ := values.([]any)
|
||||||
|
for _, extension := range list {
|
||||||
|
if text, ok := extension.(string); ok {
|
||||||
|
extensions = append(extensions, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pattern := patterns(extensions); pattern != "" {
|
||||||
|
name, _ := value["description"].(string)
|
||||||
|
if name == "" {
|
||||||
|
name = pattern
|
||||||
|
}
|
||||||
|
filters = append(filters, guigo.FileFilter{Name: name, Pattern: pattern})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append(filters, guigo.FileFilter{Name: "所有文件", Pattern: "*.*"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialogError 将用户取消与业务错误区分
|
||||||
|
func dialogError(err error) error {
|
||||||
|
if errors.Is(err, guigo.ErrCanceled) {
|
||||||
|
return &guigo.BridgeError{Name: "AbortError", Message: "已取消"}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// encode 将二进制输出转为窗口内资源
|
||||||
|
func (h *Host) encode(value any) any {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case []byte:
|
||||||
|
return h.files.bytes(v)
|
||||||
|
case map[string]any:
|
||||||
|
out := make(map[string]any, len(v))
|
||||||
|
for key, item := range v {
|
||||||
|
out[key] = h.encode(item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case []any:
|
||||||
|
out := make([]any, len(v))
|
||||||
|
for i, item := range v {
|
||||||
|
out[i] = h.encode(item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openWriter 将授权目标转换为原生事务写入器
|
||||||
|
func (h *Host) openWriter(ctx context.Context, value any, name string) (io.WriteCloser, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
handle, _ := value.(map[string]any)
|
||||||
|
id, _ := handle["nativeHandle"].(string)
|
||||||
|
h.files.mu.Lock()
|
||||||
|
entry := h.files.entries[id]
|
||||||
|
directory := entry != nil && entry.directory
|
||||||
|
h.files.mu.Unlock()
|
||||||
|
if directory {
|
||||||
|
parts := strings.Split(strings.ReplaceAll(name, "\\", "/"), "/")
|
||||||
|
for i, part := range parts {
|
||||||
|
child, err := h.files.child(id, part, i < len(parts)-1, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
id = child["nativeHandle"].(string)
|
||||||
|
}
|
||||||
|
h.files.mu.Lock()
|
||||||
|
owned := h.files.entries[id].owned
|
||||||
|
h.files.mu.Unlock()
|
||||||
|
if !owned {
|
||||||
|
return nil, fmt.Errorf("输出文件已存在")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h.files.begin(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reserveOutput 为一次转换预留独立输出文件或目录
|
||||||
|
func (h *Host) reserveOutput(ctx context.Context, request webui.NativeOutputRequest) (webui.NativeOutputTarget, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return webui.NativeOutputTarget{}, err
|
||||||
|
}
|
||||||
|
handle, _ := request.Destination.(map[string]any)
|
||||||
|
id, _ := handle["nativeHandle"].(string)
|
||||||
|
base, name, err := h.files.reserve(id, request.Base, request.Extension, request.Paged)
|
||||||
|
return webui.NativeOutputTarget{Destination: request.Destination, Base: base, Name: name}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupOutput 回滚本次转换创建的输出入口
|
||||||
|
func (h *Host) cleanupOutput(ctx context.Context, target webui.NativeOutputTarget) error {
|
||||||
|
handle, _ := target.Destination.(map[string]any)
|
||||||
|
id, _ := handle["nativeHandle"].(string)
|
||||||
|
return h.files.remove(id, target.Name, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFonts 建立系统字体目录并登记按需读取资源
|
||||||
|
func (h *Host) readFonts() {
|
||||||
|
for _, name := range ofdgo.FontSystemFiles() {
|
||||||
|
data, err := os.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
faces, err := (ofdgo.FontFile{Name: filepath.Base(name), Data: data}).Faces()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
handle, err := h.files.add(name, false)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
file, err := h.files.file(handle["nativeHandle"].(string))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, face := range faces {
|
||||||
|
h.fonts = append(h.fonts, map[string]any{"index": face.Index, "postscriptName": face.PostScriptName, "fullName": face.FullName, "family": face.Family, "style": face.Style, "file": file})
|
||||||
|
for _, alias := range []string{face.FullName, face.Family, face.PostScriptName, strings.TrimSpace(face.Family + " " + face.Style)} {
|
||||||
|
if alias != "" {
|
||||||
|
h.fontFiles[alias+".ttf"] = name + "\x00" + strconv.Itoa(face.Index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadFont 按转换请求读取单个系统字体
|
||||||
|
func (h *Host) loadFont(ctx context.Context, name string) ([]byte, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
h.fontOnce.Do(h.readFonts)
|
||||||
|
parts := strings.Split(h.fontFiles[name], "\x00")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return nil, fmt.Errorf("系统字体不可用")
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
index, _ := strconv.Atoi(parts[1])
|
||||||
|
return (ofdgo.FontFile{Data: data}).Face(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// close 取消任务并释放当前窗口持有的会话
|
||||||
|
func (h *Host) close() {
|
||||||
|
h.mu.Lock()
|
||||||
|
for _, cancel := range h.cancels {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
engines := h.engines
|
||||||
|
h.engines = make(map[string]*webui.NativeEngine)
|
||||||
|
h.mu.Unlock()
|
||||||
|
for _, engine := range engines {
|
||||||
|
_ = engine.Close()
|
||||||
|
}
|
||||||
|
_ = h.files.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// windowOptions 创建原生窗口配置,默认沿用既有数据目录
|
||||||
|
// 入参: dataDir 自定义浏览器数据目录
|
||||||
|
// 返回: guigo.Options 窗口配置, error 配置错误
|
||||||
|
func windowOptions(dataDir string) (guigo.Options, error) {
|
||||||
|
if dataDir == "" {
|
||||||
|
cache, err := os.UserCacheDir()
|
||||||
|
if err != nil {
|
||||||
|
return guigo.Options{}, err
|
||||||
|
}
|
||||||
|
dataDir = filepath.Join(cache, "OFDGo", "webview")
|
||||||
|
}
|
||||||
|
dataDir, err := filepath.Abs(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
return guigo.Options{}, err
|
||||||
|
}
|
||||||
|
icon, err := windowIcon()
|
||||||
|
if err != nil {
|
||||||
|
return guigo.Options{}, err
|
||||||
|
}
|
||||||
|
return guigo.Options{
|
||||||
|
AppID: applicationID,
|
||||||
|
Title: "OFDGo",
|
||||||
|
Icon: icon,
|
||||||
|
Width: 1440,
|
||||||
|
Height: 900,
|
||||||
|
MinWidth: 360,
|
||||||
|
MinHeight: 240,
|
||||||
|
UserDataDir: dataDir,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// windowIcon 将共用SVG图标转换为透明PNG,不生成外置文件
|
||||||
|
// 返回: []byte PNG图标, error 转换错误
|
||||||
|
func windowIcon() ([]byte, error) {
|
||||||
|
data, err := webuiassets.NativeFS.ReadFile("ofdgo.svg")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
graphic, err := canvas.ParseSVG(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
image := rasterizer.Draw(graphic, canvas.DPMM(512/graphic.W), canvas.DefaultColorSpace)
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
if err := png.Encode(&buffer, image); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buffer.Bytes(), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
// 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 webview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fileEntry 保存窗口授权的文件或目录
|
||||||
|
type fileEntry struct {
|
||||||
|
path string
|
||||||
|
name string
|
||||||
|
data []byte
|
||||||
|
file *os.File
|
||||||
|
directory bool
|
||||||
|
owned bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileStore 管理窗口范围内的文件授权和临时数据
|
||||||
|
type fileStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
entries map[string]*fileEntry
|
||||||
|
writers map[string]*fileWriter
|
||||||
|
temporary string
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileWriter 在同目录临时文件中保存输出
|
||||||
|
type fileWriter struct {
|
||||||
|
id string
|
||||||
|
mu sync.Mutex
|
||||||
|
store *fileStore
|
||||||
|
file *os.File
|
||||||
|
target string
|
||||||
|
err error
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// newFileStore 创建独立文件授权表
|
||||||
|
func newFileStore() (*fileStore, error) {
|
||||||
|
dir, err := os.MkdirTemp("", "ofdgo-webview-")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &fileStore{entries: make(map[string]*fileEntry), writers: make(map[string]*fileWriter), temporary: dir}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// token 生成不暴露本机路径的资源标识
|
||||||
|
func token() string { return rand.Text() }
|
||||||
|
|
||||||
|
// add 登记已有授权文件
|
||||||
|
func (s *fileStore) add(name string, directory bool) (map[string]any, error) {
|
||||||
|
info, err := os.Stat(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if info.IsDir() != directory {
|
||||||
|
return nil, fmt.Errorf("文件类型不匹配")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
id := token()
|
||||||
|
s.entries[id] = &fileEntry{path: name, name: filepath.Base(name), directory: directory}
|
||||||
|
return s.descriptor(id), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// save 登记用户选择的输出路径
|
||||||
|
func (s *fileStore) save(name string) map[string]any {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
id := token()
|
||||||
|
s.entries[id] = &fileEntry{path: name, name: filepath.Base(name)}
|
||||||
|
return s.descriptor(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// descriptor 返回文件句柄,不暴露本机路径
|
||||||
|
func (s *fileStore) descriptor(id string) map[string]any {
|
||||||
|
entry := s.entries[id]
|
||||||
|
kind := "file"
|
||||||
|
if entry.directory {
|
||||||
|
kind = "directory"
|
||||||
|
}
|
||||||
|
return map[string]any{"nativeHandle": id, "name": entry.name, "kind": kind}
|
||||||
|
}
|
||||||
|
|
||||||
|
// file 创建独立读取快照并返回资源描述
|
||||||
|
func (s *fileStore) file(id string) (map[string]any, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
entry := s.entries[id]
|
||||||
|
if entry == nil || entry.directory {
|
||||||
|
return nil, fmt.Errorf("文件授权无效")
|
||||||
|
}
|
||||||
|
size := int64(len(entry.data))
|
||||||
|
if entry.data == nil {
|
||||||
|
file, err := openReadFile(entry.path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
file.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
size = info.Size()
|
||||||
|
id = token()
|
||||||
|
entry = &fileEntry{path: entry.path, name: entry.name, file: file}
|
||||||
|
s.entries[id] = entry
|
||||||
|
}
|
||||||
|
return map[string]any{"nativeFile": id, "name": entry.name, "size": size, "url": "/__native/data/" + id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve 按授权标识读取文件并保持随机读取句柄
|
||||||
|
func (s *fileStore) resolve(id string) (io.ReaderAt, int64, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
entry := s.entries[id]
|
||||||
|
if entry == nil || entry.directory {
|
||||||
|
return nil, 0, fmt.Errorf("文件授权无效")
|
||||||
|
}
|
||||||
|
if entry.data != nil {
|
||||||
|
return bytes.NewReader(entry.data), int64(len(entry.data)), nil
|
||||||
|
}
|
||||||
|
if entry.file == nil {
|
||||||
|
file, err := openReadFile(entry.path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
entry.file = file
|
||||||
|
}
|
||||||
|
info, err := entry.file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return entry.file, info.Size(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bytes 登记原生输出,前端通过资源通道读取
|
||||||
|
func (s *fileStore) bytes(data []byte) map[string]any {
|
||||||
|
if data == nil {
|
||||||
|
data = []byte{}
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
id := token()
|
||||||
|
s.entries[id] = &fileEntry{data: data, name: "data"}
|
||||||
|
return map[string]any{"nativeFile": id, "size": len(data), "name": "data", "url": "/__native/data/" + id, "temporary": true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// release 释放已传递完成的内存结果
|
||||||
|
func (s *fileStore) release(id string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if entry := s.entries[id]; entry != nil && entry.data != nil {
|
||||||
|
delete(s.entries, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// child 在已授权目录中查找或创建直接子项
|
||||||
|
func (s *fileStore) child(id, name string, directory, create bool) (map[string]any, error) {
|
||||||
|
if !fs.ValidPath(name) || name == "." || strings.ContainsAny(name, "/\\:") {
|
||||||
|
return nil, fmt.Errorf("文件名无效")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
parent := s.entries[id]
|
||||||
|
if parent == nil || !parent.directory {
|
||||||
|
return nil, fmt.Errorf("目录授权无效")
|
||||||
|
}
|
||||||
|
path := filepath.Join(parent.path, name)
|
||||||
|
for key, entry := range s.entries {
|
||||||
|
if entry.path == path && entry.directory == directory {
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
return s.descriptor(key), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
root, err := os.OpenRoot(parent.path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer root.Close()
|
||||||
|
info, err := root.Stat(name)
|
||||||
|
created := false
|
||||||
|
if errors.Is(err, os.ErrNotExist) && create {
|
||||||
|
if directory {
|
||||||
|
err = root.Mkdir(name, 0755)
|
||||||
|
} else {
|
||||||
|
var file *os.File
|
||||||
|
file, err = root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
||||||
|
if err == nil {
|
||||||
|
err = file.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
created = true
|
||||||
|
info, err = root.Stat(name)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if info.IsDir() != directory {
|
||||||
|
return nil, fmt.Errorf("文件类型不匹配")
|
||||||
|
}
|
||||||
|
key := token()
|
||||||
|
s.entries[key] = &fileEntry{path: filepath.Join(parent.path, name), name: name, directory: directory, owned: created}
|
||||||
|
return s.descriptor(key), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reserve 为批量输出独占创建入口并避开已有文件
|
||||||
|
func (s *fileStore) reserve(id, base, extension string, directory bool) (string, string, error) {
|
||||||
|
if !fs.ValidPath(base) || base == "." || strings.ContainsAny(base+extension, "/\\:") {
|
||||||
|
return "", "", fmt.Errorf("文件名无效")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
parent := s.entries[id]
|
||||||
|
if parent == nil || !parent.directory {
|
||||||
|
return "", "", fmt.Errorf("目录授权无效")
|
||||||
|
}
|
||||||
|
root, err := os.OpenRoot(parent.path)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
defer root.Close()
|
||||||
|
for suffix := 0; ; suffix++ {
|
||||||
|
stem := base
|
||||||
|
if suffix > 0 {
|
||||||
|
stem = fmt.Sprintf("%s (%d)", base, suffix+1)
|
||||||
|
}
|
||||||
|
name := stem
|
||||||
|
if !directory {
|
||||||
|
name += "." + extension
|
||||||
|
}
|
||||||
|
if directory {
|
||||||
|
err = root.Mkdir(name, 0755)
|
||||||
|
} else {
|
||||||
|
var file *os.File
|
||||||
|
file, err = root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
||||||
|
if err == nil {
|
||||||
|
err = file.Close()
|
||||||
|
if err != nil {
|
||||||
|
root.Remove(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if errors.Is(err, os.ErrExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
s.entries[token()] = &fileEntry{path: filepath.Join(parent.path, name), name: name, directory: directory, owned: true}
|
||||||
|
return stem, name, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// begin 创建文件写入事务
|
||||||
|
func (s *fileStore) begin(id string) (*fileWriter, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.closed {
|
||||||
|
return nil, os.ErrClosed
|
||||||
|
}
|
||||||
|
entry := s.entries[id]
|
||||||
|
if entry == nil || entry.directory || entry.path == "" {
|
||||||
|
return nil, fmt.Errorf("输出授权无效")
|
||||||
|
}
|
||||||
|
file, err := os.CreateTemp(filepath.Dir(entry.path), ".ofdgo-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
writer := &fileWriter{id: token(), store: s, file: file, target: entry.path}
|
||||||
|
s.writers[writer.id] = writer
|
||||||
|
return writer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove 清理由当前窗口创建的目录子项
|
||||||
|
func (s *fileStore) remove(id, name string, recursive bool) error {
|
||||||
|
if !fs.ValidPath(name) || name == "." || strings.ContainsAny(name, "/\\:") {
|
||||||
|
return fmt.Errorf("文件名无效")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
parent := s.entries[id]
|
||||||
|
if parent == nil || !parent.directory {
|
||||||
|
return fmt.Errorf("目录授权无效")
|
||||||
|
}
|
||||||
|
target := filepath.Join(parent.path, name)
|
||||||
|
owned := false
|
||||||
|
for _, entry := range s.entries {
|
||||||
|
if entry.path == target && entry.owned {
|
||||||
|
owned = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !owned {
|
||||||
|
if _, err := os.Stat(target); errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("只能清理本次创建的输出")
|
||||||
|
}
|
||||||
|
root, err := os.OpenRoot(parent.path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer root.Close()
|
||||||
|
for key, entry := range s.entries {
|
||||||
|
if entry.path == target || recursive && strings.HasPrefix(entry.path, target+string(filepath.Separator)) {
|
||||||
|
if entry.file != nil {
|
||||||
|
entry.file.Close()
|
||||||
|
}
|
||||||
|
delete(s.entries, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if recursive {
|
||||||
|
return root.RemoveAll(name)
|
||||||
|
}
|
||||||
|
return root.Remove(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write 追加输出数据
|
||||||
|
func (w *fileWriter) Write(data []byte) (int, error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if w.closed {
|
||||||
|
return 0, os.ErrClosed
|
||||||
|
}
|
||||||
|
if w.err != nil {
|
||||||
|
return 0, w.err
|
||||||
|
}
|
||||||
|
n, err := w.file.Write(data)
|
||||||
|
if err == nil && n != len(data) {
|
||||||
|
err = io.ErrShortWrite
|
||||||
|
}
|
||||||
|
w.err = err
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 同步并替换目标文件
|
||||||
|
func (w *fileWriter) Close() error {
|
||||||
|
defer w.release()
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if w.closed {
|
||||||
|
return os.ErrClosed
|
||||||
|
}
|
||||||
|
w.closed = true
|
||||||
|
if w.err != nil {
|
||||||
|
return errors.Join(w.err, w.file.Close(), os.Remove(w.file.Name()))
|
||||||
|
}
|
||||||
|
if err := w.file.Sync(); err != nil {
|
||||||
|
w.file.Close()
|
||||||
|
os.Remove(w.file.Name())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := w.file.Close(); err != nil {
|
||||||
|
os.Remove(w.file.Name())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := replaceFile(w.file.Name(), w.target); err != nil {
|
||||||
|
os.Remove(w.file.Name())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abort 删除未提交的临时输出
|
||||||
|
func (w *fileWriter) Abort() error {
|
||||||
|
defer w.release()
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if w.closed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
w.closed = true
|
||||||
|
return errors.Join(w.file.Close(), os.Remove(w.file.Name()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// release 移除已结束的写入事务
|
||||||
|
func (w *fileWriter) release() {
|
||||||
|
w.store.mu.Lock()
|
||||||
|
defer w.store.mu.Unlock()
|
||||||
|
delete(w.store.writers, w.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve 提供窗口内资源,不建立网络监听器
|
||||||
|
func (s *fileStore) serve(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
if r.URL.Path == "/__native/input" && r.Method == http.MethodPost {
|
||||||
|
file, err := os.CreateTemp(s.temporary, "input-")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = io.Copy(file, r.Body)
|
||||||
|
err = errors.Join(err, file.Close())
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(file.Name())
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handle, err := s.add(file.Name(), false)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value, err := s.file(handle["nativeHandle"].(string))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(r.URL.Path, "/__native/data/") || r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := strings.TrimPrefix(r.URL.Path, "/__native/data/")
|
||||||
|
reader, size, err := s.resolve(id)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
entry := s.entries[id]
|
||||||
|
s.mu.Unlock()
|
||||||
|
if entry == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeContent(w, r, entry.name, time.Time{}, io.NewSectionReader(reader, 0, size))
|
||||||
|
}
|
||||||
|
|
||||||
|
// close 释放授权文件与未提交输出
|
||||||
|
func (s *fileStore) close() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.closed {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.closed = true
|
||||||
|
writers := s.writers
|
||||||
|
entries := s.entries
|
||||||
|
s.writers = make(map[string]*fileWriter)
|
||||||
|
s.entries = make(map[string]*fileEntry)
|
||||||
|
s.mu.Unlock()
|
||||||
|
var err error
|
||||||
|
for _, writer := range writers {
|
||||||
|
err = errors.Join(err, writer.Abort())
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.file != nil {
|
||||||
|
err = errors.Join(err, entry.file.Close())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(err, os.RemoveAll(s.temporary))
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package webview
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
// openReadFile 打开独立读取句柄
|
||||||
|
func openReadFile(name string) (*os.File, error) {
|
||||||
|
return os.Open(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// replaceFile 替换目标文件并保留已有读取句柄
|
||||||
|
func replaceFile(source, target string) error {
|
||||||
|
return os.Rename(source, target)
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// 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 webview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// replaceFileProc 保留目标文件属性并替换文件内容
|
||||||
|
var replaceFileProc = syscall.NewLazyDLL("kernel32.dll").NewProc("ReplaceFileW")
|
||||||
|
|
||||||
|
// openReadFile 允许原子替换路径,同时保持已有读取句柄有效
|
||||||
|
func openReadFile(name string) (*os.File, error) {
|
||||||
|
path, err := nativeFilePath(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &os.PathError{Op: "open", Path: name, Err: err}
|
||||||
|
}
|
||||||
|
handle, err := syscall.CreateFile(path, syscall.GENERIC_READ, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &os.PathError{Op: "open", Path: name, Err: err}
|
||||||
|
}
|
||||||
|
return os.NewFile(uintptr(handle), name), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// replaceFile 替换已有文件,目标不存在时直接重命名
|
||||||
|
func replaceFile(source, target string) error {
|
||||||
|
from, err := nativeFilePath(source)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
to, err := nativeFilePath(target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result, _, err := replaceFileProc.Call(uintptr(unsafe.Pointer(to)), uintptr(unsafe.Pointer(from)), 0, 0, 0, 0)
|
||||||
|
if result != 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err == syscall.ERROR_FILE_NOT_FOUND {
|
||||||
|
return os.Rename(source, target)
|
||||||
|
}
|
||||||
|
return &os.LinkError{Op: "replace", Old: source, New: target, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nativeFilePath 转换绝对路径并支持长路径与网络共享
|
||||||
|
func nativeFilePath(name string) (*uint16, error) {
|
||||||
|
path, err := filepath.Abs(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(path, `\\?\`) {
|
||||||
|
if strings.HasPrefix(path, `\\`) {
|
||||||
|
path = `\\?\UNC\` + path[2:]
|
||||||
|
} else {
|
||||||
|
path = `\\?\` + path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return syscall.UTF16PtrFromString(path)
|
||||||
|
}
|
||||||
+13
-3
@@ -377,9 +377,12 @@ func systemFontDirs() []string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// indexSystemFonts 递归读取系统字体名称,保留集合索引
|
// FontSystemFiles 获取当前系统可访问的字体文件,不读取字体内容
|
||||||
// 返回: []basefont.FileCandidate 字体候选
|
// 返回: []string 去重后的字体路径,浏览器WASM环境返回空列表
|
||||||
func indexSystemFonts() []basefont.FileCandidate {
|
func FontSystemFiles() []string {
|
||||||
|
if !canLoadSystemFonts() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
var files []string
|
var files []string
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
for _, dir := range systemFontDirs() {
|
for _, dir := range systemFontDirs() {
|
||||||
@@ -391,6 +394,13 @@ func indexSystemFonts() []basefont.FileCandidate {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexSystemFonts 递归读取系统字体名称,保留集合索引
|
||||||
|
// 返回: []basefont.FileCandidate 字体候选
|
||||||
|
func indexSystemFonts() []basefont.FileCandidate {
|
||||||
|
files := FontSystemFiles()
|
||||||
candidates := basefont.FileCandidates(files, filepath.Base)
|
candidates := basefont.FileCandidates(files, filepath.Base)
|
||||||
for i, candidate := range candidates {
|
for i, candidate := range candidates {
|
||||||
file, err := os.Open(candidate.Name)
|
file, err := os.Open(candidate.Name)
|
||||||
|
|||||||
Reference in new issue
Block a user