Files
ofdgo/assets/webui/ofdgo.js
T
2026-10-13 16:50:25 +08:00

10592 lines
454 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { CanvasEditor, canEditObject, editTextValue, missingGlyphMessage, objectEditReason, selectedText, lineShape, selectionBounds, pagePoint } from "./ofdgo_edit.js";
import { FontManager, FontPicker } from "./ofdgo_font.js";
const MM_TO_PX = 96 / 25.4;
const COMPACT_LAYOUT = window.matchMedia("(max-width: 900px)");
const DEFAULT_IMAGE_DPI = 300;
const PAGE_CACHE_LIMIT = 16;
const PAGE_CACHE_BYTES = 32 * 1024 * 1024;
const SCROLL_SEGMENT_SIZE = 1000000;
const OBJECT_CLIPBOARD_TYPE = "application/x-ofdgo-objects";
const STATUS = {
ready: "选择 OFD、PDF 文件",
opening: "正在打开文档",
engine: "正在准备引擎",
recovering: "正在恢复引擎",
fonts: "正在匹配字体",
exporting: "正在导出文档",
pageExporting: "正在导出单页",
};
const wasmRequests = new Map();
const metaContents = new WeakMap();
const batch = { items: [], formats: [], running: false, canceled: false, worker: null, requests: new Map(), sequence: 0, activeID: 0, progressTime: 0, progress: null };
const batchElements = Object.fromEntries(["Button", "Panel", "Form", "Input", "Add", "Clear", "Count", "Format", "Compression", "FontRow", "FontMode", "Destination", "DPIRow", "DPI", "List", "Empty", "Progress", "Status", "Close", "Cancel", "Start"].map(name => [name, document.querySelector(`#convert${name}`)]));
const fontBinding = { font: null, choices: [], sequence: 0 };
const fontBindingElements = Object.fromEntries(["Panel", "Form", "Title", "Name", "SourceRow", "Source", "File", "Hint", "Status", "Cancel", "Submit"].map(name => [name, document.querySelector(`#fontBinding${name}`)]));
const resourceElements = Object.fromEntries(["ManageButton", "Panel", "Form", "Kind", "List", "Empty", "Reference", "Actions", "Add", "Preview", "Export", "Rename", "Replace", "Delete", "Locate", "Close", "Binding", "BindingActions", "Page", "Object", "Pick", "Bind", "Unbind", "Viewer", "Status", "File"].map(name => [name, document.querySelector(`#resource${name}`)]));
const resourceManager = { items: [], selected: null, targets: [], pick: null, busy: false, sequence: 0, url: null, font: null, fileAction: null };
const settingsElements = Object.fromEntries(["Button", "Panel", "Form", "Title", "Kind", "Context", "Content", "Actions", "Status"].map(name => [name, document.querySelector(`#settings${name}`)]));
const settings = { sequence: 0, busy: false, page: 0, scope: "document", object: "", path: [], selected: -1, data: null, actions: [] };
let wasmPromise = null;
let remoteDownload = null;
let wasmWorker = null;
let wasmRequestID = 0;
let wasmRecoveryTimer = 0;
let textMeasure = null;
let credentialRequest = null;
let cancelPageTouch = null;
const state = {
conversionWarnings: [],
composite: null,
annotationEdit: null,
annotationCreate: null,
ready: false,
wasmExited: false,
wasmSeq: 0,
wasmRecovering: false,
wasmRecoveries: 0,
exporting: false,
signing: false,
signCanceled: false,
exportRequestID: 0,
editing: false,
selectObjects: true,
dirty: false,
editorInfo: null,
editorViews: new Map(),
savedRevision: null,
packageDirty: false,
encryptionDirty: false,
insertObject: null,
importPageCount: 0,
importEncrypted: false,
importing: false,
imageImporting: false,
createInsert: false,
createPackage: false,
createDocumentAt: null,
documentManagement: null,
versionManagement: null,
infoDocumentIndex: null,
pageSelection: new Set(),
pageSelectionAnchor: null,
pageMultiSelect: false,
pageTouchPointer: null,
outlineSelection: null,
outlineSeq: 0,
openActionEnabled: false,
openActionDocument: false,
openActionPage: null,
openActionTask: null,
openActionFrame: 0,
outlineAction: "add",
styleOriginal: null,
objectClipboard: null,
styleClipboard: null,
outlineExpanded: new Map(),
textFonts: [],
textFontID: null,
textDefaults: { type: "TextObject", size: 16 * 25.4 / 72, color: "#000000", wrap: true, align: "left", paragraphHeight: 0, letterSpacing: 0 },
ofdBytes: null,
fileName: "ofdgo.ofd",
openSeq: 0,
pageSeq: 0,
searchSeq: 0,
searchQuery: "",
searchMatches: [],
searchIndex: -1,
navigationScroll: new Map(),
fontSyncPending: false,
fontCatalogLoading: false,
fontFacesLoading: false,
fontRenderPending: false,
fontSyncing: false,
doc: null,
pageIndex: 0,
scale: 1,
rotation: 0,
panMode: false,
pan: null,
fitMode: "width",
continuous: false,
renderAnnotations: true,
renderBackend: "canvas",
renderMode: "svg",
renderDPI: DEFAULT_IMAGE_DPI,
pageCache: new Map(),
mediaPlayers: new Map(),
svgFonts: new Map(),
svgImages: new Map(),
selectedPages: new Set(),
documentSelection: null,
pageInFlight: new Map(),
pageRenderQueue: [],
pageRenderRunning: false,
pageObserver: null,
visiblePages: new Set(),
scrollFrame: 0,
pageWindow: null,
thumbnailWindow: null,
thumbnailFrame: 0,
pageDragIndex: null,
pageListCurrent: null,
thumbnailInFlight: new Set(),
thumbnailObserver: null,
visibleThumbnails: new Set(),
exportFormats: [],
exportPages: null,
exportBackdrop: false,
encryptionChanged: false,
showPages: !COMPACT_LAYOUT.matches,
showMeta: !COMPACT_LAYOUT.matches,
};
const el = {
signButton: document.querySelector("#signButton"),
signPanel: document.querySelector("#signPanel"),
signForm: document.querySelector("#signForm"),
signCertificate: document.querySelector("#signCertificate"),
signKey: document.querySelector("#signKey"),
signKeyPassword: document.querySelector("#signKeyPassword"),
signRoots: document.querySelector("#signRoots"),
signIntermediates: document.querySelector("#signIntermediates"),
signSeal: document.querySelector("#signSeal"),
signPlacementFields: document.querySelector("#signPlacementFields"),
signPlacement: document.querySelector("#signPlacement"),
signPages: document.querySelector("#signPages"),
signX: document.querySelector("#signX"),
signY: document.querySelector("#signY"),
signWidth: document.querySelector("#signWidth"),
signHeight: document.querySelector("#signHeight"),
signMode: document.querySelector("#signMode"),
signLock: document.querySelector("#signLock"),
signStatus: document.querySelector("#signStatus"),
signCancel: document.querySelector("#signCancel"),
verifyButton: document.querySelector("#verifyButton"),
verifyPanel: document.querySelector("#verifyPanel"),
verifyForm: document.querySelector("#verifyForm"),
verifyRoots: document.querySelector("#verifyRoots"),
verifyCerts: document.querySelector("#verifyCerts"),
verifyTimeRoots: document.querySelector("#verifyTimeRoots"),
verifyTokens: document.querySelector("#verifyTokens"),
verifyCRLs: document.querySelector("#verifyCRLs"),
verifyOCSP: document.querySelector("#verifyOCSP"),
verifyRequireTime: document.querySelector("#verifyRequireTime"),
verifyRequireRevocation: document.querySelector("#verifyRequireRevocation"),
verifyStatus: document.querySelector("#verifyStatus"),
verifyCancel: document.querySelector("#verifyCancel"),
securityPanel: document.querySelector("#securityPanel"),
securityEncryption: document.querySelector("#securityEncryption"),
securityMethod: document.querySelector("#securityMethod"),
securityMethodRow: document.querySelector("#securityMethodRow"),
securityScheme: document.querySelector("#securityScheme"),
securitySchemeRow: document.querySelector("#securitySchemeRow"),
encryptSaveButton: document.querySelector("#encryptSaveButton"),
credentialsPanel: document.querySelector("#credentialsPanel"),
credentialsType: document.querySelector("#credentialsType"),
credentialsTypeRow: document.querySelector("#credentialsTypeRow"),
credentialsUserRow: document.querySelector("#credentialsUserRow"),
credentialsPasswordRow: document.querySelector("#credentialsPasswordRow"),
credentialsCertificateRow: document.querySelector("#credentialsCertificateRow"),
credentialsKeyRow: document.querySelector("#credentialsKeyRow"),
credentialsCertificate: document.querySelector("#credentialsCertificate"),
credentialsKey: document.querySelector("#credentialsKey"),
credentialsKeyPassword: document.querySelector("#credentialsKeyPassword"),
credentialsKeyPasswordRow: document.querySelector("#credentialsKeyPasswordRow"),
credentialsForm: document.querySelector("#credentialsForm"),
credentialsUser: document.querySelector("#credentialsUser"),
credentialsPassword: document.querySelector("#credentialsPassword"),
credentialsStatus: document.querySelector("#credentialsStatus"),
credentialsCancel: document.querySelector("#credentialsCancel"),
encryptionPanel: document.querySelector("#encryptionPanel"),
encryptionForm: document.querySelector("#encryptionForm"),
encryptionKeep: document.querySelector("#encryptionKeep"),
encryptionCurrentRow: document.querySelector("#encryptionCurrentRow"),
encryptionCurrent: document.querySelector("#encryptionCurrent"),
encryptionChange: document.querySelector("#encryptionChange"),
encryptionCancel: document.querySelector("#encryptionCancel"),
encryptionSubmit: document.querySelector("#encryptionSubmit"),
encryptionStatus: document.querySelector("#encryptionStatus"),
encryptionType: document.querySelector("#encryptionType"),
encryptionUserRow: document.querySelector("#encryptionUserRow"),
encryptionPasswordRow: document.querySelector("#encryptionPasswordRow"),
encryptionConfirmRow: document.querySelector("#encryptionConfirmRow"),
encryptionCertificatesRow: document.querySelector("#encryptionCertificatesRow"),
encryptionCertificates: document.querySelector("#encryptionCertificates"),
encryptionUser: document.querySelector("#encryptionUser"),
encryptionPassword: document.querySelector("#encryptionPassword"),
encryptionConfirm: document.querySelector("#encryptionConfirm"),
ofdInput: document.querySelector("#ofdInput"),
ofdButton: document.querySelector("#ofdButton"),
newButton: document.querySelector("#newButton"),
editButton: document.querySelector("#editButton"),
editNotice: document.querySelector("#editNotice"),
editorTools: document.querySelector("#editorTools"),
selectObjectButton: document.querySelector("#selectObjectButton"),
deleteObjectButton: document.querySelector("#deleteObjectButton"),
editObjectButton: document.querySelector("#editObjectButton"),
compositeBackButton: document.querySelector("#compositeBackButton"),
copyObjectButton: document.querySelector("#copyObjectButton"),
objectOrder: document.querySelector("#objectOrder"),
objectAlign: document.querySelector("#objectAlign"),
objectDistribute: document.querySelector("#objectDistribute"),
objectRotate: document.querySelector("#objectRotate"),
objectFlip: document.querySelector("#objectFlip"),
imageFit: document.querySelector("#imageFit"),
cropImageButton: document.querySelector("#cropImageButton"),
saveImageButton: document.querySelector("#saveImageButton"),
resetCropButton: document.querySelector("#resetCropButton"),
multiSelectButton: document.querySelector("#multiSelectButton"),
textAlign: document.querySelector("#textAlign"),
textWrap: document.querySelector("#textWrap"),
textLineHeight: document.querySelector("#textLineHeight"),
textSpacing: document.querySelector("#textSpacing"),
textFont: document.querySelector("#textFont"),
textFontToggle: document.querySelector("#textFontToggle"),
textFontList: document.querySelector("#textFontList"),
textFontAdd: document.querySelector("#textFontAdd"),
paragraphButton: document.querySelector("#paragraphButton"),
infoButton: document.querySelector("#infoButton"),
documentSelect: document.querySelector("#documentSelect"),
documentActions: document.querySelector("#documentActions"),
documentManageButton: document.querySelector("#documentManageButton"),
documentPanel: document.querySelector("#documentPanel"),
documentForm: document.querySelector("#documentForm"),
documentList: document.querySelector("#documentList"),
documentStatus: document.querySelector("#documentStatus"),
documentClose: document.querySelector("#documentClose"),
documentRow: document.querySelector("#documentRow"),
documentAddButton: document.querySelector("#documentAddButton"),
documentDeleteButton: document.querySelector("#documentDeleteButton"),
documentUpButton: document.querySelector("#documentUpButton"),
documentDownButton: document.querySelector("#documentDownButton"),
versionManageButton: document.querySelector("#versionManageButton"),
versionTitle: document.querySelector("#versionTitle"),
versionNameRow: document.querySelector("#versionNameRow"),
versionActions: document.querySelector("#versionActions"),
versionPanel: document.querySelector("#versionPanel"),
versionForm: document.querySelector("#versionForm"),
versionNotice: document.querySelector("#versionNotice"),
versionList: document.querySelector("#versionList"),
versionName: document.querySelector("#versionName"),
versionStatus: document.querySelector("#versionStatus"),
versionAdd: document.querySelector("#versionAdd"),
versionRename: document.querySelector("#versionRename"),
versionDefault: document.querySelector("#versionDefault"),
versionDelete: document.querySelector("#versionDelete"),
versionSelect: document.querySelector("#versionSelect"),
versionExport: document.querySelector("#versionExport"),
versionClose: document.querySelector("#versionClose"),
infoPanel: document.querySelector("#infoPanel"),
infoForm: document.querySelector("#infoForm"),
infoTitle: document.querySelector("#infoTitle"),
infoCustomRows: document.querySelector("#infoCustomRows"),
infoCustomAdd: document.querySelector("#infoCustomAdd"),
infoAuthor: document.querySelector("#infoAuthor"),
infoSubject: document.querySelector("#infoSubject"),
infoCancel: document.querySelector("#infoCancel"),
infoStatus: document.querySelector("#infoStatus"),
paragraphPanel: document.querySelector("#paragraphPanel"),
paragraphForm: document.querySelector("#paragraphForm"),
paragraphCancel: document.querySelector("#paragraphCancel"),
paragraphStatus: document.querySelector("#paragraphStatus"),
leftIndent: document.querySelector("#leftIndent"),
rightIndent: document.querySelector("#rightIndent"),
firstLineIndent: document.querySelector("#firstLineIndent"),
importPanel: document.querySelector("#importPanel"),
importForm: document.querySelector("#importForm"),
importFile: document.querySelector("#importFile"),
importRange: document.querySelector("#importRange"),
importPagesRow: document.querySelector("#importPagesRow"),
importPages: document.querySelector("#importPages"),
importPosition: document.querySelector("#importPosition"),
importOutlines: document.querySelector("#importOutlines"),
importStatus: document.querySelector("#importStatus"),
importCancel: document.querySelector("#importCancel"),
importSubmit: document.querySelector("#importSubmit"),
textSize: document.querySelector("#textSize"),
textColor: document.querySelector("#textColor"),
drawLineButton: document.querySelector("#drawLineButton"),
arrowTool: document.querySelector("#arrowTool"),
drawRectangleButton: document.querySelector("#drawRectangleButton"),
drawEllipseButton: document.querySelector("#drawEllipseButton"),
eraseButton: document.querySelector("#eraseButton"),
eraseMode: document.querySelector("#eraseMode"),
shapeFill: document.querySelector("#shapeFill"),
shapeFillColor: document.querySelector("#shapeFillColor"),
shapeStroke: document.querySelector("#shapeStroke"),
shapeStrokeColor: document.querySelector("#shapeStrokeColor"),
shapeWidth: document.querySelector("#shapeWidth"),
undoButton: document.querySelector("#undoButton"),
redoButton: document.querySelector("#redoButton"),
insertTextButton: document.querySelector("#insertTextButton"),
insertImageButton: document.querySelector("#insertImageButton"),
addPageButton: document.querySelector("#addPageButton"),
copyPageButton: document.querySelector("#copyPageButton"),
deletePageButton: document.querySelector("#deletePageButton"),
movePagePrevButton: document.querySelector("#movePagePrevButton"),
movePageNextButton: document.querySelector("#movePageNextButton"),
pageSettingsButton: document.querySelector("#pageSettingsButton"),
batchPagesButton: document.querySelector("#batchPagesButton"),
batchPagesPanel: document.querySelector("#batchPagesPanel"),
batchPagesForm: document.querySelector("#batchPagesForm"),
batchPageAction: document.querySelector("#batchPageAction"),
batchPageRange: document.querySelector("#batchPageRange"),
batchPagePositionRow: document.querySelector("#batchPagePositionRow"),
batchPagePosition: document.querySelector("#batchPagePosition"),
batchPagesStatus: document.querySelector("#batchPagesStatus"),
batchPagesCancel: document.querySelector("#batchPagesCancel"),
batchPagesSubmit: document.querySelector("#batchPagesSubmit"),
objectStyleButton: document.querySelector("#objectStyleButton"),
copyStyleButton: document.querySelector("#copyStyleButton"),
pasteStyleButton: document.querySelector("#pasteStyleButton"),
objectBoundsButton: document.querySelector("#objectBoundsButton"),
objectBoundsPanel: document.querySelector("#objectBoundsPanel"),
objectBoundsForm: document.querySelector("#objectBoundsForm"),
objectBoundsCancel: document.querySelector("#objectBoundsCancel"),
objectBoundsStatus: document.querySelector("#objectBoundsStatus"),
objectX: document.querySelector("#objectX"),
objectY: document.querySelector("#objectY"),
objectWidth: document.querySelector("#objectWidth"),
objectHeight: document.querySelector("#objectHeight"),
objectAspect: document.querySelector("#objectAspect"),
objectStylePanel: document.querySelector("#objectStylePanel"),
sourceTextPanel: document.querySelector("#sourceTextPanel"),
sourceTextForm: document.querySelector("#sourceTextForm"),
sourceTextValue: document.querySelector("#sourceTextValue"),
sourceTextPreview: document.querySelector("#sourceTextPreview"),
sourceTextStatus: document.querySelector("#sourceTextStatus"),
sourceTextCancel: document.querySelector("#sourceTextCancel"),
objectStyleForm: document.querySelector("#objectStyleForm"),
groupObjectsButton: document.querySelector("#groupObjectsButton"),
ungroupObjectButton: document.querySelector("#ungroupObjectButton"),
objectOpacity: document.querySelector("#objectOpacity"),
objectLinkFields: document.querySelector("#objectLinkFields"),
objectLinkKind: document.querySelector("#objectLinkKind"),
objectLinkAddress: document.querySelector("#objectLinkAddress"),
objectLinkPage: document.querySelector("#objectLinkPage"),
objectLinkAddressRow: document.querySelector("#objectLinkAddressRow"),
objectLinkPageRow: document.querySelector("#objectLinkPageRow"),
objectGradientFields: document.querySelector("#objectGradientFields"),
gradientTarget: document.querySelector("#gradientTarget"),
objectBorderFields: document.querySelector("#objectBorderFields"),
borderEnabled: document.querySelector("#borderEnabled"),
borderWidth: document.querySelector("#borderWidth"),
borderColor: document.querySelector("#borderColor"),
borderColorOriginal: document.querySelector("#borderColorOriginal"),
borderHorizontal: document.querySelector("#borderHorizontal"),
borderVertical: document.querySelector("#borderVertical"),
patternTarget: document.querySelector("#patternTarget"),
gradientKind: document.querySelector("#gradientKind"),
gradientControls: document.querySelector("#gradientControls"),
gradientAngle: document.querySelector("#gradientAngle"),
gradientStops: document.querySelector("#gradientStops"),
gradientAdd: document.querySelector("#gradientAdd"),
objectPatternFields: document.querySelector("#objectPatternFields"),
patternWidth: document.querySelector("#patternWidth"),
patternHeight: document.querySelector("#patternHeight"),
patternXStep: document.querySelector("#patternXStep"),
patternYStep: document.querySelector("#patternYStep"),
patternCTM: document.querySelector("#patternCTM"),
objectStrokeFields: document.querySelector("#objectStrokeFields"),
objectDash: document.querySelector("#objectDash"),
objectDashRow: document.querySelector("#objectDashRow"),
objectDashPattern: document.querySelector("#objectDashPattern"),
objectDashOffset: document.querySelector("#objectDashOffset"),
objectCap: document.querySelector("#objectCap"),
objectJoin: document.querySelector("#objectJoin"),
objectStyleStatus: document.querySelector("#objectStyleStatus"),
objectStyleCancel: document.querySelector("#objectStyleCancel"),
outlinePanel: document.querySelector("#outlinePanel"),
outlineForm: document.querySelector("#outlineForm"),
outlineTitle: document.querySelector("#outlineTitle"),
outlineParent: document.querySelector("#outlineParent"),
outlinePosition: document.querySelector("#outlinePosition"),
outlinePositionRow: document.querySelector("#outlinePositionRow"),
outlinePage: document.querySelector("#outlinePage"),
outlineStatus: document.querySelector("#outlineStatus"),
outlineCancel: document.querySelector("#outlineCancel"),
outlineSubmit: document.querySelector("#outlineSubmit"),
pagePanel: document.querySelector("#pagePanel"),
pageForm: document.querySelector("#pageForm"),
pagePortrait: document.querySelector("#pagePortrait"),
pageLandscape: document.querySelector("#pageLandscape"),
pageWidth: document.querySelector("#pageWidth"),
pageRangeRow: document.querySelector("#pageRangeRow"),
pageRange: document.querySelector("#pageRange"),
pageHeight: document.querySelector("#pageHeight"),
pageStatus: document.querySelector("#pageStatus"),
pageCancel: document.querySelector("#pageCancel"),
saveButton: document.querySelector("#saveButton"),
createPanel: document.querySelector("#createPanel"),
createMode: document.querySelector("#createMode"),
createImages: document.querySelector("#createImages"),
createSize: document.querySelector("#createSize"),
createMargin: document.querySelector("#createMargin"),
createPosition: document.querySelector("#createPosition"),
createSubmit: document.querySelector("#createSubmit"),
createForm: document.querySelector("#createForm"),
createName: document.querySelector("#createName"),
createWidth: document.querySelector("#createWidth"),
createHeight: document.querySelector("#createHeight"),
createStatus: document.querySelector("#createStatus"),
createCancel: document.querySelector("#createCancel"),
insertPanel: document.querySelector("#insertPanel"),
insertForm: document.querySelector("#insertForm"),
insertImage: document.querySelector("#insertImage"),
insertFitRow: document.querySelector("#insertFitRow"),
insertFit: document.querySelector("#insertFit"),
insertStatus: document.querySelector("#insertStatus"),
insertCancel: document.querySelector("#insertCancel"),
insertSubmit: document.querySelector("#insertSubmit"),
fontInput: document.querySelector("#fontInput"),
fontDirectoryInput: document.querySelector("#fontDirectoryInput"),
togglePagesButton: document.querySelector("#togglePagesButton"),
toggleMetaButton: document.querySelector("#toggleMetaButton"),
fontAddButton: document.querySelector("#fontAddButton"),
fontDirectoryButton: document.querySelector("#fontDirectoryButton"),
localFontButton: document.querySelector("#localFontButton"),
prevButton: document.querySelector("#prevButton"),
nextButton: document.querySelector("#nextButton"),
pageControl: document.querySelector(".page-control"),
pageInput: document.querySelector("#pageInput"),
pageTotal: document.querySelector("#pageTotal"),
zoomOutButton: document.querySelector("#zoomOutButton"),
zoomInButton: document.querySelector("#zoomInButton"),
zoomLabel: document.querySelector("#zoomLabel"),
fitButton: document.querySelector("#fitButton"),
fitHeightButton: document.querySelector("#fitHeightButton"),
rotateButton: document.querySelector("#rotateButton"),
panButton: document.querySelector("#panButton"),
continuousButton: document.querySelector("#continuousButton"),
annotationButton: document.querySelector("#annotationButton"),
imageDPI: document.querySelector("#imageDPI"),
outputCompression: document.querySelector("#outputCompression"),
compressionValue: document.querySelector("#compressionValue"),
dpiValue: document.querySelector("#dpiValue"),
formatValue: document.querySelector("#formatValue"),
exportFormat: document.querySelector("#exportFormat"),
exportPageButton: document.querySelector("#exportPageButton"),
exportButton: document.querySelector("#exportButton"),
exportPanel: document.querySelector("#exportPanel"),
exportTitle: document.querySelector("#exportTitle"),
exportForm: document.querySelector("#exportForm"),
exportAll: document.querySelector("#exportAll"),
exportSpecified: document.querySelector("#exportSpecified"),
exportRangeRow: document.querySelector("#exportRangeRow"),
exportRange: document.querySelector("#exportRange"),
exportRangeStatus: document.querySelector("#exportRangeStatus"),
exportCancel: document.querySelector("#exportCancel"),
exportSubmit: document.querySelector("#exportSubmit"),
emptyState: document.querySelector("#emptyState"),
progressPanel: document.querySelector("#progressPanel"),
progressLabel: document.querySelector("#progressLabel"),
cancelExportButton: document.querySelector("#cancelExportButton"),
progressBar: document.querySelector("#progressBar"),
pageFrame: document.querySelector("#pageFrame"),
viewerPanel: document.querySelector(".viewer-panel"),
svgHost: document.querySelector("#svgHost"),
pageListPanel: document.querySelector(".page-list-panel"),
pageListTitle: document.querySelector("#pageListTitle"),
navigationTabs: document.querySelector("#navigationTabs"),
pageSelectionTools: document.querySelector("#pageSelectionTools"),
pageSelectionCount: document.querySelector("#pageSelectionCount"),
selectAllPages: document.querySelector("#selectAllPages"),
finishPageSelection: document.querySelector("#finishPageSelection"),
navigationContent: document.querySelector("#navigationContent"),
pagesTab: document.querySelector("#pagesTab"),
annotationsTab: document.querySelector("#annotationsTab"),
annotationsPanel: document.querySelector("#annotationsPanel"),
annotationsFilter: document.querySelector("#annotationsFilter"),
annotationsKind: document.querySelector("#annotationsKind"),
annotationsStatus: document.querySelector("#annotationsStatus"),
annotationsList: document.querySelector("#annotationsList"),
outlinesTab: document.querySelector("#outlinesTab"),
pageList: document.querySelector("#pageList"),
outlineList: document.querySelector("#outlineList"),
searchTab: document.querySelector("#searchTab"),
searchPanel: document.querySelector("#searchPanel"),
searchForm: document.querySelector("#searchForm"),
searchInput: document.querySelector("#searchInput"),
searchStatus: document.querySelector("#searchStatus"),
searchCount: document.querySelector("#searchCount"),
searchPrev: document.querySelector("#searchPrev"),
searchNext: document.querySelector("#searchNext"),
searchResults: document.querySelector("#searchResults"),
metaPanel: document.querySelector(".meta-panel"),
appPanel: document.querySelector("#appPanel"),
renderVectorButton: document.querySelector("#renderVectorButton"),
renderRasterButton: document.querySelector("#renderRasterButton"),
offlineStatus: document.querySelector("#offlineStatus"),
refreshAppButton: document.querySelector("#refreshAppButton"),
metaFile: document.querySelector("#metaFile"),
metaTitle: document.querySelector("#metaTitle"),
conversionNotice: document.querySelector("#conversionNotice"),
conversionWarnings: document.querySelector("#conversionWarnings"),
metaSubject: document.querySelector("#metaSubject"),
metaAuthor: document.querySelector("#metaAuthor"),
metaCreationDate: document.querySelector("#metaCreationDate"),
metaModDate: document.querySelector("#metaModDate"),
metaCreator: document.querySelector("#metaCreator"),
metaType: document.querySelector("#metaType"),
metaVersion: document.querySelector("#metaVersion"),
metaSignatures: document.querySelector("#metaSignatures"),
metaFonts: document.querySelector("#metaFonts"),
attachmentDialog: document.querySelector("#attachmentDialog"),
attachmentForm: document.querySelector("#attachmentForm"),
attachmentTitle: document.querySelector("#attachmentTitle"),
attachmentFile: document.querySelector("#attachmentFile"),
attachmentFileRow: document.querySelector("#attachmentFileRow"),
attachmentName: document.querySelector("#attachmentName"),
attachmentNameRow: document.querySelector("#attachmentNameRow"),
attachmentStatus: document.querySelector("#attachmentStatus"),
attachmentCancel: document.querySelector("#attachmentCancel"),
attachmentSubmit: document.querySelector("#attachmentSubmit"),
signaturePanel: document.querySelector("#signaturePanel"),
structurePanel: document.querySelector("#structurePanel"),
structureList: document.querySelector("#structureList"),
structureSummary: document.querySelector("#structureSummary"),
structureManage: document.querySelector("#structureManage"),
structureDialog: document.querySelector("#structureDialog"),
structureForm: document.querySelector("#structureForm"),
structureEntry: document.querySelector("#structureEntry"),
structureNodeSelect: document.querySelector("#structureNodeSelect"),
structureAction: document.querySelector("#structureAction"),
structureFields: document.querySelector("#structureFields"),
structureType: document.querySelector("#structureType"),
structureName: document.querySelector("#structureName"),
structureNamespace: document.querySelector("#structureNamespace"),
structureText: document.querySelector("#structureText"),
structureAttributes: document.querySelector("#structureAttributes"),
structureBinding: document.querySelector("#structureBinding"),
structureReferences: document.querySelector("#structureReferences"),
structureStatus: document.querySelector("#structureStatus"),
structureSubmit: document.querySelector("#structureSubmit"),
signatureSummary: document.querySelector("#signatureSummary"),
signatureList: document.querySelector("#signatureList"),
annotationNote: document.querySelector("#annotationNote"),
annotationTool: document.querySelector("#annotationTool"),
penTool: document.querySelector("#penTool"),
annotationCreate: document.querySelector("#annotationCreate"),
annotationCreateStatus: document.querySelector("#annotationCreateStatus"),
objectPicker: document.querySelector("#objectPicker"),
objectPickerList: document.querySelector("#objectPickerList"),
annotationForm: document.querySelector("#annotationForm"),
annotationFields: document.querySelector("#annotationFields"),
annotationRemark: document.querySelector("#annotationRemark"),
annotationCreator: document.querySelector("#annotationCreator"),
annotationStatus: document.querySelector("#annotationStatus"),
annotationClose: document.querySelector("#annotationClose"),
annotationSubmit: document.querySelector("#annotationSubmit"),
annotationDetail: document.querySelector("#annotationDetail"),
docFontList: document.querySelector("#docFontList"),
docFontSummary: document.querySelector("#docFontSummary"),
availableFontSummary: document.querySelector("#availableFontSummary"),
fontPermissionHint: document.querySelector("#fontPermissionHint"),
fontList: document.querySelector("#fontList"),
statusText: document.querySelector("#statusText"),
};
const fontManager = new FontManager({
onChange: scheduleFontSync,
onPermissionChange: updateFontPermissionHint,
});
const fontPicker = new FontPicker(el.textFont, el.textFontToggle, el.textFontList, changeTextFont, loadEditorFonts);
const annotationFields = Object.fromEntries(["CreateForm", "CreateCancel", "Content", "Old", "Font", "Text", "TextRow", "Image", "ImageRow", "Author", "LinkKind", "Address", "AddressRow", "Target", "TargetRow", "X", "Y", "Width", "Height", "Angle", "Opacity", "Tile", "Placement", "PlacementLabel", "Pick", "Scope", "Pages"].map(key => [key, document.querySelector(`#annotation${key}`)]));
el.editorTools.addEventListener("scroll", () => fontPicker.position());
const canvasEditor = new CanvasEditor(el.viewerPanel, {
textControls: [el.textFont, el.textFontToggle, el.textFontAdd, el.textSize, el.textColor],
busy: () => document.body.hasAttribute("aria-busy"),
rotation: () => state.rotation,
canInsert: (index) => pageCan("insert", index),
onSelect: (item, previous) => {
updateObjectControls(item);
const reason = objectEditReason(item), previousReason = objectEditReason(previous);
if (state.editing && state.doc && !document.body.hasAttribute("aria-busy")
&& (reason || previousReason && el.statusText.textContent === previousReason)) {
setStatus(reason || pageStatus(state.pageIndex, state.doc.pageCount));
}
},
onTransform: (item, change) => changeDocument(item.items ? "ofdgoTransformObjects" : "ofdgoTransformObject", item,
change.x + item.x * (1 - change.scale), change.y + item.y * (1 - change.scale), change.scale),
onNudgeChange: () => {
updatePendingChanges();
el.undoButton.disabled = !state.ready || state.exporting || !state.editorInfo?.canUndo && !canvasEditor.nudgeChanged();
},
onReshape: (item, box) => changeDocument("ofdgoReshapeObject", item, box.x, box.y, box.width, box.height, Boolean(item.oriented)),
onResize: (item, box) => changeDocument("ofdgoResizeObjects", { ...item, id: [item.id] }, box.x, box.y, box.width, box.height),
onTextWidth: (item, box) => changeDocument("ofdgoLayoutText", item, box.x - item.x, box.width, true, item.align || "", item.paragraphHeight || 0, item.letterSpacing || 0, ...textIndents(item)),
onCrop: (item, box) => changeDocument("ofdgoCropImage", item, box.x, box.y, box.width, box.height),
onCropChange: () => {
updatePendingChanges();
updateObjectControls(canvasEditor.selected);
syncSelection();
if (state.fontRenderPending) window.setTimeout(refreshPendingFonts, 0);
},
onDelete: (item) => changeDocument(item.items ? "ofdgoDeleteObjects" : "ofdgoDeleteObject", item),
onErase: (items, box, points) => {
const blocked = items.find(item => !canEditObject(item, box ? "arrange" : "delete"));
if (blocked) { setStatus(objectEditReason(blocked) || "对象暂不可擦除"); return false; }
const item = { index: items[0].index, id: items.map(item => item.id), scoped: items.every(item => item.scoped) };
if (points) return changeDocument("ofdgoEraseObjectsPath", item, points);
return box ? changeDocument("ofdgoEraseObjects", item, box.x, box.y, box.width, box.height)
: changeDocument("ofdgoDeleteObjects", item);
},
onEdit: editCanvasObject,
onPick: openObjectPicker,
onExitScope: () => {
if (!state.composite) return false;
exitCompositeScope();
return true;
},
drawStyle: shapeStyle,
onTool: () => {
updateDrawingControls();
const entry = state.annotationCreate;
if (entry?.picking && canvasEditor.tool !== "annotation:watermark") {
queueMicrotask(() => {
if (state.annotationCreate === entry && entry.picking) {
entry.picking = false;
if (!state.editing || entry.openSeq !== state.openSeq || canvasEditor.tool) {
state.annotationCreate = null;
return;
}
el.annotationCreate.showModal();
}
});
}
},
onDraw: (index, shape, box, style) => changeDocument("ofdgoInsertShape", null, index, shape,
box.x, box.y, box.width, box.height, style.fill, style.fillColor, style.stroke, style.strokeColor, style.lineWidth),
onDrawText: beginCanvasText,
onDrawAnnotation: placeAnnotation,
onDrawInk: (index, points, style, pressure) => changeDocument("ofdgoInsertInk", null, index, JSON.stringify(points), style.lineWidth, pressure, style.strokeColor),
onPreviewText: (item, value, font, size) => callWASM("ofdgoPreviewText", item.index, item.id, value, font || null, size),
onCommitText: (item, value, fontData, color = null) => item.draft
? changeDocument("ofdgoInsertText", null, item.index, value, fontData || item.fontData, item.x, item.y, item.size, item.color, item.width, item.wrap, item.align, item.paragraphHeight, item.letterSpacing, ...textIndents(item))
: changeDocument("ofdgoUpdateText", item, value, fontData || null, item.size, color),
onTextChange: () => {
const editing = canvasEditor.input;
updatePendingChanges();
if (!editing) {
updateObjectControls(canvasEditor.selected);
updateTextFonts(canvasEditor.selected, true);
syncSelection();
if (state.fontRenderPending) window.setTimeout(refreshPendingFonts, 0);
}
},
});
function updatePendingChanges() {
setDirty(state.packageDirty || state.encryptionDirty || Boolean(state.editorInfo) && (state.editorInfo.revision !== state.savedRevision
|| canvasEditor.textChanged() || canvasEditor.cropChanged() || canvasEditor.nudgeChanged()));
}
el.documentSelect.addEventListener("change", () => switchDocument(Number(el.documentSelect.value)));
el.editorTools.addEventListener("pointerdown", (event) => {
if ((canvasEditor.input || canvasEditor.nudge) && event.target.closest("button")) event.preventDefault();
});
function editorClick(button, action) {
button.addEventListener("click", async () => {
if ((canvasEditor.nudge || canvasEditor.nudgeCommit) && !await canvasEditor.commitNudge()) return;
if (canvasEditor.input && !await canvasEditor.commitText()) return;
if (canvasEditor.crop && !await canvasEditor.commitCrop()) return;
return action();
});
}
editorClick(el.selectObjectButton, () => {
if (document.body.hasAttribute("aria-busy")) {
return;
}
state.selectObjects = !state.selectObjects || state.panMode || Boolean(canvasEditor.tool);
canvasEditor.setTool("");
if (state.selectObjects && state.panMode) {
togglePan();
}
updateEditorTools();
});
editorClick(el.deleteObjectButton, () => {
if (canvasEditor.selected) {
canvasEditor.options.onDelete(canvasEditor.selected);
}
});
editorClick(el.multiSelectButton, () => {
canvasEditor.multiple = !canvasEditor.multiple;
el.multiSelectButton.setAttribute("aria-pressed", String(canvasEditor.multiple));
});
editorClick(el.compositeBackButton, exitCompositeScope);
editorClick(el.editObjectButton, () => {
if (canvasEditor.selected) {
editCanvasObject(canvasEditor.selected);
}
});
el.textSize.addEventListener("change", () => changeTextStyle(false));
el.textColor.addEventListener("change", () => changeTextStyle(true));
editorClick(el.textWrap, () => changeParagraph(!currentTextStyle().wrap));
el.textAlign.addEventListener("change", () => changeParagraph());
el.textLineHeight.addEventListener("change", () => changeParagraph());
editorClick(el.paragraphButton, () => {
const item = currentTextStyle();
for (const key of ["leftIndent", "rightIndent", "firstLineIndent"]) el[key].value = item[key] || 0;
el.paragraphStatus.textContent = "";
el.paragraphPanel.showModal();
});
el.paragraphCancel.addEventListener("click", () => el.paragraphPanel.close());
editorClick(el.documentAddButton, () => openCreatePanel(false, true));
editorClick(el.documentManageButton, openDocumentManagement);
resourceElements.ManageButton.addEventListener("click", openResourceManagement);
resourceElements.Close.addEventListener("click", () => resourceElements.Panel.close());
resourceElements.Form.addEventListener("submit", event => event.preventDefault());
resourceElements.Panel.addEventListener("cancel", event => { if (resourceManager.busy) event.preventDefault(); });
resourceElements.Panel.addEventListener("close", () => { resourceManager.sequence++; releaseResourcePreview(); });
resourceElements.Kind.addEventListener("change", () => resourceTask(renderResourceList));
resourceElements.Reference.addEventListener("change", () => resourceTask(syncResourceReference));
resourceElements.Page.addEventListener("change", () => resourceTask(() => loadResourceTargets("")));
resourceElements.Object.addEventListener("change", updateResourceControls);
resourceElements.Pick.addEventListener("click", () => resourceTask(beginResourcePick));
resourceElements.Preview.addEventListener("click", () => resourceTask(previewManagedResource));
resourceElements.Export.addEventListener("click", () => {
const item = resourceManager.selected;
if (item) downloadResource({ id: JSON.stringify([item.kind, item.id]), fileName: resourceFileName(item) }, "ofdgoExportResource", "资源");
});
resourceElements.Locate.addEventListener("click", () => resourceTask(locateManagedResource));
resourceElements.Add.addEventListener("click", () => {
if (!state.editing) return;
const kind = resourceElements.Kind.value;
if (kind === "Font") openFontFile(el.fontInput);
else if (kind === "Image") openInsertPanel();
else if (kind === "Attachment") openAttachmentDialog("add");
else if (kind === "ColorSpace" || kind === "DrawParam") openResourceSettings(kind, "");
else chooseResourceFile("add");
});
resourceElements.Replace.addEventListener("click", async () => {
const item = resourceManager.selected;
if (!state.editing || !item) return;
if (item.kind === "ColorSpace" || item.kind === "DrawParam") {
await openResourceSettings(item.kind, item.id);
} else if (item.kind === "Font") {
const font = state.doc.fonts?.find(font => font.id === item.id) || {id: item.id, fontName: item.name, embedded: Boolean(item.file)};
await openFontBinding(font);
if (!fontBindingElements.Panel.open) await resourceTask(loadManagedResources);
} else chooseResourceFile("replace");
});
resourceElements.Rename.addEventListener("click", () => {
const item = resourceManager.selected;
if (item?.kind === "Attachment") openAttachmentDialog("rename", item);
});
resourceElements.Delete.addEventListener("click", () => resourceTask(async () => {
const item = resourceManager.selected;
if (!item) return;
const message = item.kind === "Attachment" ? `删除附件“${item.name}”?相关附件链接将一并移除。` : `删除资源“${item.name}”?`;
if (window.confirm(message)) await changeManagedResource("delete", item);
}));
for (const [button, action] of [[resourceElements.Bind, "bind"], [resourceElements.Unbind, "unbind"]]) {
button.addEventListener("click", () => resourceTask(() => changeManagedResource(action, resourceManager.selected)));
}
resourceElements.File.addEventListener("change", () => resourceTask(async () => {
const request = resourceManager.fileAction, file = resourceElements.File.files[0];
if (!file || !request || request.sequence !== state.openSeq || !state.editing) return;
await changeManagedResource(request.action, request.item, file);
}));
for (const dialog of [fontBindingElements.Panel, el.attachmentDialog]) {
dialog.addEventListener("close", () => { if (resourceElements.Panel.open) resourceTask(loadManagedResources); });
}
editorClick(el.versionManageButton, openVersionManagement);
el.versionClose.addEventListener("click", () => el.versionPanel.close());
el.versionPanel.addEventListener("cancel", event => { if (el.versionForm.inert) event.preventDefault(); });
el.versionPanel.addEventListener("close", () => {
if (!el.versionPanel.open) {
state.versionManagement = null;
el.versionList.replaceChildren();
el.versionName.value = el.versionStatus.textContent = "";
}
});
el.versionForm.addEventListener("submit", event => event.preventDefault());
el.versionName.addEventListener("input", updateVersionControls);
for (const [button, action] of [[el.versionAdd, "add"], [el.versionRename, "rename"], [el.versionDefault, "default"], [el.versionDelete, "delete"], [el.versionSelect, "select"]]) {
button.addEventListener("click", () => changeVersion(action));
}
el.versionList.addEventListener("keydown", event => {
const manager = state.versionManagement;
if (!manager?.entries || manager.loading || document.body.hasAttribute("aria-busy")) return;
let index = manager.entries.findIndex(entry => entry.id === manager.id);
if (event.key === "ArrowUp") index--;
else if (event.key === "ArrowDown") index++;
else if (event.key === "Home") index = 0;
else if (event.key === "End") index = manager.entries.length - 1;
else return;
event.preventDefault();
selectManagedVersion(manager.entries[Math.max(0, Math.min(index, manager.entries.length - 1))].id, true);
});
el.documentClose.addEventListener("click", () => el.documentPanel.close());
el.documentPanel.addEventListener("cancel", event => { if (el.documentForm.inert) event.preventDefault(); });
el.documentPanel.addEventListener("close", () => {
if (!el.documentPanel.open) {
state.documentManagement = null;
el.documentList.replaceChildren();
el.documentStatus.textContent = "";
}
});
el.documentList.addEventListener("keydown", event => {
if (!state.documentManagement || !["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const count = state.doc.documents.length, index = state.documentManagement.index;
selectManagedDocument(event.key === "Home" ? 0 : event.key === "End" ? count - 1
: Math.max(0, Math.min(count - 1, index + (event.key === "ArrowUp" ? -1 : 1))), true);
});
editorClick(el.documentDeleteButton, () => {
const index = managedDocumentIndex(), entry = state.doc.documents[index];
if (window.confirm(`删除文档“${entry.title || "未命名"}”?`)) return changeDocument("ofdgoChangeDocument", null, "delete", index);
});
editorClick(el.documentUpButton, () => {
const index = managedDocumentIndex();
return changeDocument("ofdgoChangeDocument", null, "move", index, index - 1);
});
editorClick(el.documentDownButton, () => {
const index = managedDocumentIndex();
return changeDocument("ofdgoChangeDocument", null, "move", index, index + 1);
});
editorClick(el.infoButton, () => {
state.infoDocumentIndex = managedDocumentIndex();
const info = state.documentManagement?.entries?.[state.infoDocumentIndex] || state.doc;
for (const key of ["Title", "Author", "Subject"]) el[`info${key}`].value = info[key.toLowerCase()] || "";
el.infoCustomRows.replaceChildren();
for (const [index, field] of (info.customData || []).entries()) addCustomDataRow(field, index);
el.infoStatus.textContent = "";
el.infoPanel.showModal();
});
el.infoCancel.addEventListener("click", () => el.infoPanel.close());
el.infoPanel.addEventListener("cancel", event => { if (el.infoForm.inert) event.preventDefault(); });
el.infoPanel.addEventListener("close", () => { if (!el.infoPanel.open) state.infoDocumentIndex = null; });
el.infoCustomAdd.addEventListener("click", () => addCustomDataRow());
el.infoForm.addEventListener("submit", async event => {
event.preventDefault();
const fields = [...el.infoCustomRows.children].map(row => ({Name: row.children[0].value, Value: row.children[1].value, Source: row.sourceIndex}));
const args = [el.infoTitle.value, el.infoAuthor.value, el.infoSubject.value, JSON.stringify(fields)];
if (state.infoDocumentIndex !== null) args.push(state.infoDocumentIndex);
if (await changeDocument("ofdgoUpdateInfo", null, ...args)) el.infoPanel.close();
});
function addCustomDataRow(field = {Name: "", Value: ""}, sourceIndex) {
const row = document.createElement("div");
row.sourceIndex = sourceIndex;
row.className = "info-custom-row";
for (const [key, label] of [["Name", "名称"], ["Value", "内容"]]) {
const input = document.createElement("input");
input.type = "text";
input.value = field[key];
input.setAttribute("aria-label", label);
input.placeholder = label;
row.append(input);
}
const remove = document.createElement("button");
remove.type = "button";
remove.className = "small-button";
remove.textContent = "×";
remove.title = "删除字段";
remove.setAttribute("aria-label", "删除字段");
remove.addEventListener("click", () => row.remove());
row.append(remove);
el.infoCustomRows.append(row);
}
el.annotationClose.addEventListener("click", () => el.annotationNote.close());
el.annotationNote.addEventListener("close", () => { state.annotationEdit = null; });
el.objectPicker.addEventListener("close", () => { if (!el.objectPicker.open) el.objectPickerList.replaceChildren(); });
el.penTool.addEventListener("change", () => {
const tool = el.penTool.value;
el.penTool.value = "";
canvasEditor.select(null);
toggleDrawingTool(tool);
});
el.annotationTool.addEventListener("change", async () => {
const kind = el.annotationTool.value;
el.annotationTool.value = "";
if (kind === "objects") { openObjectPicker(); return; }
let selected = canvasEditor.selected;
if (state.composite?.key.startsWith("annotation:")) {
const id = state.composite.key.split("/")[0];
selected = { ...state.pageCache.get(state.composite.index)?.objects.find(item => item.id === id), index: state.composite.index, id, type: "Annotation" };
}
if (kind === "details" || kind === "replace") {
if (selected?.type !== "Annotation") { setStatus("请先选择注解"); return; }
try {
const info = await callWASM("ofdgoReadAnnotation", selected.index, selected.id);
if (kind === "details") editAnnotationDetails({ ...selected, ...info });
else openAnnotationCreate(kind, selected.index, null, selected, info);
} catch (error) { showError(error, false); }
return;
}
if (kind === "link" && selected?.annotationType === "Link") {
try {
const info = await callWASM("ofdgoReadAnnotation", selected.index, selected.id);
openAnnotationCreate(kind, selected.index, selected, selected, info);
} catch (error) { showError(error, false); }
} else {
resetCompositeScope();
updateControls();
if (kind === "watermark") openAnnotationCreate(kind, state.pageIndex);
else {
canvasEditor.select(null);
toggleDrawingTool(`annotation:${kind}`);
}
}
});
annotationFields.CreateCancel.addEventListener("click", () => el.annotationCreate.close());
el.annotationCreate.addEventListener("close", () => {
if (!state.annotationCreate?.picking) state.annotationCreate = null;
});
annotationFields.Content.addEventListener("change", updateAnnotationFields);
annotationFields.Height.addEventListener("input", () => { state.annotationCreate.height = annotationFields.Height.value; });
annotationFields.LinkKind.addEventListener("change", updateAnnotationFields);
annotationFields.Old.addEventListener("change", () => { annotationFields.Text.value = annotationFields.Old.value; });
annotationFields.Tile.addEventListener("change", () => {
annotationFields.Placement.value = "page";
state.annotationCreate.area = null;
updateAnnotationFields();
});
annotationFields.Placement.addEventListener("change", () => {
if (annotationFields.Placement.value === "custom") pickWatermarkArea();
else state.annotationCreate.area = null;
});
annotationFields.Pick.addEventListener("click", pickWatermarkArea);
annotationFields.Scope.addEventListener("change", updateAnnotationFields);
annotationFields.CreateForm.addEventListener("submit", async event => {
event.preventDefault();
const entry = state.annotationCreate;
if (!entry || !annotationFields.CreateForm.reportValidity() || document.body.hasAttribute("aria-busy")) return;
const openSeq = state.openSeq;
const value = key => annotationFields[key].value;
const image = entry.kind === "stamp" || entry.kind === "watermark" && value("Content") === "image";
let font = null, data = null;
try {
if (image) data = new Uint8Array(await annotationFields.Image.files[0].arrayBuffer());
if (entry.kind === "watermark" && !image || entry.kind === "replace" && value("Font") === "current") {
const choice = state.textDefaults.fontChoice || state.textFonts[Number(fontPicker.value)];
if (!choice || choice.disabled) throw new Error("请先选择可用字体");
font = await readTextFont(choice);
}
if (openSeq !== state.openSeq || state.annotationCreate !== entry) return;
const options = { kind: entry.kind, id: entry.item?.id.replace("annotation:", "") || "", text: value("Text"), old: value("Old"),
creator: value("Author"), pages: entry.kind === "watermark" && value("Scope") !== "custom"
? value("Scope") === "all" ? `1-${state.doc.pageCount}` : String(entry.index + 1) : value("Pages"),
uri: value("Address"), target: value("LinkKind") === "page" ? Number(value("Target")) : 0,
x: Number(value("X")), y: Number(value("Y")), width: entry.kind === "note" ? 5 : Number(value("Width")), height: entry.kind === "note" ? 5 : Number(value("Height")),
angle: Number(value("Angle")), alpha: Math.round((100 - Number(value("Opacity"))) * 255 / 100), tile: value("Tile") === "tile",
area: entry.kind === "watermark" && value("Placement") === "custom" ? entry.area : null,
size: state.textDefaults.size, color: state.textDefaults.color };
if (entry.kind === "link" && entry.item) {
options.base = entry.info?.linkBase || "";
options.keep = value("LinkKind") === "keep" || value("LinkKind") === entry.info?.linkKind
&& (value("LinkKind") === "uri" ? value("Address") === entry.info.linkURI : Number(value("Target")) === entry.info.linkPage);
}
if (await changeDocument("ofdgoWriteAnnotation", null, entry.index, JSON.stringify(options), font, data)) el.annotationCreate.close();
} catch (error) { el.annotationCreateStatus.textContent = error.message; }
});
el.annotationForm.addEventListener("submit", async event => {
if (!state.annotationEdit) return;
event.preventDefault();
const fromSummary = state.annotationEdit.fromSummary;
if (await changeAnnotations(state.annotationEdit, "update", el.annotationRemark.value, el.annotationCreator.value)) {
el.annotationNote.close();
if (fromSummary) showNavigation(el.annotationsTab);
canvasEditor.focus();
}
});
el.paragraphForm.addEventListener("submit", async event => {
event.preventDefault();
if (document.body.hasAttribute("aria-busy") || !el.paragraphForm.reportValidity()) return;
const item = currentTextStyle();
const indents = Object.fromEntries(["leftIndent", "rightIndent", "firstLineIndent"].map(key => [key, Number(el[key].value)]));
if (item === state.textDefaults) {
Object.assign(item, indents);
} else {
if (!confirmTextReflow(item)) return;
const saved = await changeDocument("ofdgoLayoutText", item, null, null, Boolean(item.wrap), item.align || "left",
item.paragraphHeight || 0, item.letterSpacing || 0, ...textIndents(indents));
if (!saved) return;
}
el.paragraphPanel.close();
});
el.textSpacing.addEventListener("change", () => changeParagraph());
el.shapeWidth.addEventListener("focus", () => { el.shapeWidth.defaultValue = el.shapeWidth.value; });
for (const input of [el.textSize, el.shapeWidth, el.textLineHeight, el.textSpacing]) {
input.addEventListener("keydown", (event) => {
if (event.isComposing) return;
if (event.key === "Enter" || event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
if (event.repeat) return;
if (event.key === "Escape") {
updateObjectControls(canvasEditor.selected, true);
if (input === el.shapeWidth && canvasEditor.selected?.type !== "PathObject") {
input.value = input.defaultValue;
}
input.blur();
} else {
input.dispatchEvent(new Event("change"));
}
}
});
}
editorClick(el.copyObjectButton, () => {
const item = canvasEditor.selected;
if (!item) {
return;
}
return changeDocument("ofdgoCopyObjects", { ...item, id: canvasEditor.items().map(member => member.id) }, 3, 3);
});
el.objectOrder.addEventListener("change", () => {
const action = el.objectOrder.value;
el.objectOrder.value = "";
const item = canvasEditor.selected;
if (!item || !action) {
return;
}
return changeDocument("ofdgoOrderObjects", { ...item, id: canvasEditor.items().map(member => member.id) }, action);
});
el.objectAlign.addEventListener("change", () => {
const alignment = el.objectAlign.value;
el.objectAlign.value = "";
if (canvasEditor.selected && alignment) {
return changeDocument(canvasEditor.selected.items ? "ofdgoAlignObjects" : "ofdgoAlignObject", canvasEditor.selected, alignment);
}
});
el.objectDistribute.addEventListener("change", () => {
const axis = el.objectDistribute.value;
el.objectDistribute.value = "";
if (axis && canvasEditor.items().length >= 3) {
return changeDocument("ofdgoDistributeObjects", canvasEditor.selected, axis);
}
});
for (const [select, method] of [[el.objectRotate, "ofdgoRotateObjects"], [el.objectFlip, "ofdgoFlipObjects"]]) {
select.addEventListener("change", () => {
const value = select.value, item = canvasEditor.selected;
select.value = "";
if (!item || !value || canvasEditor.crop) return;
return changeDocument(method, { ...item, id: canvasEditor.items().map(member => member.id) },
select === el.objectRotate ? Number(value) : value);
});
}
el.cropImageButton.addEventListener("click", () => {
if (document.body.hasAttribute("aria-busy")) return;
if (canvasEditor.crop) return canvasEditor.commitCrop();
const item = canvasEditor.selected;
if (item?.type === "ImageObject") return startImageCrop(item);
});
el.imageFit.addEventListener("change", () => {
const mode = el.imageFit.value, item = canvasEditor.selected;
el.imageFit.value = "";
if (mode && item?.type === "ImageObject" && !canvasEditor.crop) return changeDocument("ofdgoFitImage", item, mode);
});
el.resetCropButton.addEventListener("click", () => {
if (document.body.hasAttribute("aria-busy")) return;
if (canvasEditor.crop) {
canvasEditor.closeCrop();
return;
}
const item = canvasEditor.selected;
if (item?.scoped) return changeDocument("ofdgoResetCompositeCrop", item);
if (canEditObject(item, "resetCrop")) return changeDocument("ofdgoResetImageCrop", item);
if (item?.imageBounds) return canvasEditor.options.onCrop(item, item.imageBounds);
});
editorClick(el.undoButton, () => changeDocument("ofdgoUndo"));
editorClick(el.redoButton, () => changeDocument("ofdgoRedo"));
for (const [button, tool] of [[el.drawLineButton, "line"], [el.drawRectangleButton, "rectangle"], [el.drawEllipseButton, "ellipse"]]) {
editorClick(button, () => toggleDrawingTool(tool));
}
editorClick(el.eraseButton, () => toggleDrawingTool(el.eraseMode.value));
el.eraseMode.addEventListener("change", () => {
if (canvasEditor.tool.startsWith("erase-")) canvasEditor.setTool(el.eraseMode.value);
});
for (const input of [el.shapeFill, el.shapeFillColor, el.shapeStroke, el.shapeStrokeColor, el.shapeWidth]) {
input.addEventListener("change", changeShapeStyle);
}
el.addPageButton.addEventListener("change", async () => {
const action = el.addPageButton.value;
el.addPageButton.value = "";
if (canvasEditor.input && !await canvasEditor.commitText()) return;
if (canvasEditor.crop && !await canvasEditor.commitCrop()) return;
if (action === "import") { openImportPanel(); return; }
if (action === "images") { openCreatePanel(true); return; }
const page = currentPageInfo();
return changeDocument("ofdgoChangePage", null, "add", state.pageIndex, page.width, page.height);
});
el.importCancel.addEventListener("click", () => state.importing ? cancelExport() : el.importPanel.close());
el.importPanel.addEventListener("cancel", event => {
if (state.importing) { event.preventDefault(); cancelExport(); }
});
el.importPanel.addEventListener("close", () => {
state.importPageCount = 0;
state.importEncrypted = false;
el.importForm.reset();
if (state.ready) callWASM("ofdgoLoadImport", null).catch(err => showError(err, false));
});
el.importFile.addEventListener("change", loadImportFile);
el.importRange.addEventListener("change", () => {
const custom = el.importRange.value === "custom";
el.importPagesRow.hidden = !custom;
el.importPages.disabled = !custom;
el.importPages.required = custom;
if (custom) el.importPages.focus();
});
el.importForm.addEventListener("submit", async event => {
event.preventDefault();
if (document.body.hasAttribute("aria-busy") || !state.importPageCount || !el.importForm.reportValidity()) return;
const position = { before:state.pageIndex, after:state.pageIndex+1, first:0, last:state.doc.pageCount }[el.importPosition.value];
const options = [];
if (state.importEncrypted) {
if (!window.confirm(state.doc.encryption?.encrypted ? "按目标文档的加密策略导入,是否继续?" : "目标未加密,导入内容将按明文保存,是否继续?")) return;
options.push(true);
}
const saved = await changeDocument("ofdgoImportPages", null, el.importRange.value === "custom" ? el.importPages.value : "", position, el.importOutlines.checked, ...options);
if (saved) el.importPanel.close();
});
editorClick(el.copyPageButton, () => changeSelectedPages("copy"));
editorClick(el.deletePageButton, () => changeSelectedPages("delete"));
editorClick(el.movePagePrevButton, () => changeSelectedPages("move", -1));
editorClick(el.movePageNextButton, () => changeSelectedPages("move", 1));
editorClick(el.pageSettingsButton, openPagePanel);
editorClick(el.batchPagesButton, openBatchPages);
function openBatchPages() {
el.batchPageAction.value = "copy";
el.batchPagesSubmit.textContent = "复制";
el.batchPagePositionRow.hidden = true;
el.batchPagePosition.value = "after";
el.batchPageRange.value = selectedPageRange();
el.batchPagesStatus.textContent = "";
el.batchPagesPanel.showModal();
el.batchPageRange.select();
}
el.batchPagesCancel.addEventListener("click", () => el.batchPagesPanel.close());
el.batchPageAction.addEventListener("change", () => {
el.batchPagesSubmit.textContent = { copy: "复制", move: "移动", delete: "删除", save: "另存" }[el.batchPageAction.value];
el.batchPagePositionRow.hidden = el.batchPageAction.value !== "move";
});
el.batchPagesForm.addEventListener("submit", async event => {
event.preventDefault();
if (document.body.hasAttribute("aria-busy") || !el.batchPagesForm.reportValidity()) return;
const args = [el.batchPageAction.value, el.batchPageRange.value];
if (args[0] === "save") {
const seq = state.openSeq;
try {
const indices = await callWASM("ofdgoParsePageRange", args[1]);
if (seq === state.openSeq && el.batchPagesPanel.open && await exportFile(true, indices, "ofd")) el.batchPagesPanel.close();
} catch (err) {
if (seq === state.openSeq) el.batchPagesStatus.textContent = err.message;
}
return;
}
if (args[0] === "move") args.push({ before: state.pageIndex, after: state.pageIndex + 1, first: 0, last: state.doc.pageCount }[el.batchPagePosition.value]);
if (await changeDocument("ofdgoBatchPages", null, ...args)) el.batchPagesPanel.close();
});
editorClick(el.objectStyleButton, openObjectStyle);
editorClick(el.objectBoundsButton, openObjectBounds);
for (const [button, ungroup] of [[el.groupObjectsButton, false], [el.ungroupObjectButton, true]]) {
button.addEventListener("click", async () => {
const item = canvasEditor.selected;
if (await changeDocument("ofdgoGroupObjects", { ...item, id: canvasEditor.items().map(member => member.id) }, ungroup)) el.objectStylePanel.close();
});
}
el.arrowTool.addEventListener("change", async () => {
const tool = el.arrowTool.value;
el.arrowTool.value = "";
if (await canvasEditor.commitText() && await canvasEditor.commitCrop()) {
const item = canvasEditor.selected;
if (lineShape(item?.shape)) {
const box = item.geometry;
await changeDocument("ofdgoReshapeLine", item, box.x, box.y, box.width, box.height, tool);
return;
}
setPan(false);
state.selectObjects = true;
canvasEditor.setTool(tool);
updateControls();
}
});
el.copyStyleButton.addEventListener("click", async () => {
const item = canvasEditor.selected, seq = state.openSeq;
setBusy(true);
try {
await callWASM("ofdgoCaptureStyle", item.index, item.id, state.composite?.key || "");
if (seq === state.openSeq) { state.styleClipboard = item.type; el.objectStylePanel.close(); }
} catch (err) { if (seq === state.openSeq) el.objectStyleStatus.textContent = err.message; }
finally { if (seq === state.openSeq) setBusy(false); }
});
el.pasteStyleButton.addEventListener("click", async () => {
const item = canvasEditor.selected;
if (await changeDocument("ofdgoPasteStyle", { ...item, id: canvasEditor.items().map(member => member.id) })) el.objectStylePanel.close();
});
el.objectBoundsCancel.addEventListener("click", () => el.objectBoundsPanel.close());
for (const input of [el.objectWidth, el.objectHeight]) input.addEventListener("input", () => {
if (!el.objectAspect.checked || !input.value || el.objectWidth.disabled || el.objectHeight.disabled) return;
const box = state.boundsOriginal;
if (input === el.objectWidth) setObjectDimension(el.objectHeight, Number(input.value) * box.height / box.width);
else setObjectDimension(el.objectWidth, Number(input.value) * box.width / box.height);
});
el.objectAspect.addEventListener("change", () => {
if (el.objectAspect.checked) setObjectDimension(el.objectHeight, objectDimension(el.objectWidth) * state.boundsOriginal.height / state.boundsOriginal.width);
});
el.objectBoundsForm.addEventListener("submit", async event => {
event.preventDefault();
const item = canvasEditor.selected;
const values = [el.objectX, el.objectY, el.objectWidth, el.objectHeight].map(objectDimension);
let saved;
if (item.oriented) {
const [a, b, c, d, e, f] = item.oriented.matrix, determinant = a * d - b * c;
const x = values[0] - e, y = values[1] - f;
saved = await changeDocument("ofdgoReshapeObject", item, (d * x - c * y) / determinant, (a * y - b * x) / determinant, values[2] / Math.hypot(a, b), values[3] / Math.hypot(c, d), true);
} else if (item.shape) {
if (lineShape(item.shape)) { values[2] *= Math.sign(item.geometry.width); values[3] *= Math.sign(item.geometry.height); }
saved = await changeDocument("ofdgoReshapeObject", item, ...values);
} else saved = await changeDocument("ofdgoResizeObjects", { ...item, id: canvasEditor.items().map(member => member.id) }, ...values);
if (saved) el.objectBoundsPanel.close();
});
el.objectStyleCancel.addEventListener("click", () => el.objectStylePanel.close());
el.sourceTextCancel.addEventListener("click", () => el.sourceTextPanel.close());
el.sourceTextValue.addEventListener("input", () => {
clearTimeout(state.sourceTextTimer);
state.sourceTextTimer = setTimeout(previewPositionedText, 150);
});
el.sourceTextPanel.addEventListener("close", () => {
clearTimeout(state.sourceTextTimer);
state.sourceText = null;
el.sourceTextPreview.replaceChildren();
});
el.sourceTextForm.addEventListener("submit", async event => {
event.preventDefault();
const item = state.sourceText?.item;
if (!item) return;
if (await changeDocument("ofdgoUpdateText", item, el.sourceTextValue.value, null, item.size, null)) el.sourceTextPanel.close();
});
el.objectLinkKind.addEventListener("change", updateObjectLinkFields);
el.gradientKind.addEventListener("change", () => {
el.gradientControls.hidden = el.gradientKind.value === "keep";
el.gradientAngle.disabled = el.gradientKind.value !== "linear";
for (const input of el.gradientStops.querySelectorAll("input")) input.disabled = el.gradientKind.value === "keep";
});
el.gradientTarget.addEventListener("change", () => {
state.gradientForms[state.gradientTarget].form = gradientForm();
loadGradientForm(el.gradientTarget.value);
});
el.patternTarget.addEventListener("change", () => {
state.patternForms[state.patternTarget] = patternForm();
loadPatternForm(el.patternTarget.value);
});
el.gradientAdd.addEventListener("click", () => addGradientStop(50, "#808080", 0));
el.objectDash.addEventListener("change", () => {
el.objectDashRow.hidden = el.objectDash.value !== "custom";
el.objectDashPattern.required = !el.objectDashRow.hidden;
});
el.objectStyleForm.addEventListener("submit", async event => {
event.preventDefault();
if (!el.objectGradientFields.hidden && el.gradientKind.value !== "keep" && el.gradientStops.children.length < 2) {
el.objectStyleStatus.textContent = "至少保留两个色标";
return;
}
const style = readObjectStyle();
const item = canvasEditor.selected;
if (await changeDocument("ofdgoStyleObjects", { ...item, id: canvasEditor.items().map(member => member.id) }, style)) el.objectStylePanel.close();
});
el.outlineCancel.addEventListener("click", () => el.outlinePanel.close());
el.structureManage.addEventListener("click", openStructureDialog);
document.querySelector("#structureCancel").addEventListener("click", () => el.structureDialog.close());
el.structureEntry.addEventListener("change", refreshStructureNodes);
el.structureNodeSelect.addEventListener("change", refreshStructureFields);
el.structureAction.addEventListener("change", refreshStructureFields);
document.querySelector("#structureAttributeAdd").addEventListener("click", () => addStructureAttribute());
document.querySelector("#structureReferenceAdd").addEventListener("click", () => addStructureReference());
document.querySelector("#structureSelection").addEventListener("click", () => {
const items = canvasEditor.items();
if (!items.length) { el.structureStatus.textContent = "尚未选择图元"; return; }
const page = state.doc.pages[items[0].index];
addStructureReference({ pageId: page.id, objectIds: items.map(item => item.id) });
});
el.structureForm.addEventListener("submit", async event => {
event.preventDefault();
if (!state.editing || document.body.hasAttribute("aria-busy")) return;
const index = Number(el.structureEntry.value), path = JSON.parse(el.structureNodeSelect.value || "[]");
const action = index < 0 ? "create" : el.structureAction.value;
let original = state.doc.structures?.[index]?.root;
for (const part of path) original = original?.children?.[part];
const text = action === "update" && original?.text?.replace(/\r\n?/g, "\n") === el.structureText.value
? original.text : el.structureText.value;
const node = { name: el.structureName.value, namespace: el.structureNamespace.value, text,
attributes: [...el.structureAttributes.children].map(row => ({ name: row.querySelector('[name="name"]').value, namespace: row.querySelector('[name="namespace"]').value, value: row.querySelector('[name="value"]').value })) };
const references = [...el.structureReferences.children].map(row => ({ pageId: row.querySelector('[name="page"]').value,
objectIds: row.querySelector('[name="ids"]').value.split(/[,\s]+/u).filter(Boolean) })).filter(ref => ref.objectIds.length);
el.structureStatus.textContent = "";
const saved = await changeDocument("ofdgoChangeStructure", null, JSON.stringify({ action, index, path, typeId: el.structureType.value, node, references }));
if (saved) el.structureDialog.close();
});
el.outlineForm.addEventListener("submit", async event => {
event.preventDefault();
const path = state.outlineAction === "add" ? JSON.parse(el.outlineParent.value) : state.outlineSelection;
const page = el.outlinePage.value === "" ? -1 : Number(el.outlinePage.value) - 1;
const saved = state.outlineAction === "move"
? await changeDocument("ofdgoMoveOutline", null, state.outlineSelection, JSON.parse(el.outlineParent.value), Number(el.outlinePosition.value) - 1)
: await changeDocument("ofdgoChangeOutline", null, state.outlineAction, path, el.outlineTitle.value, page);
if (saved) el.outlinePanel.close();
});
el.outlineParent.addEventListener("change", updateOutlinePosition);
el.pageCancel.addEventListener("click", () => el.pagePanel.close());
el.pageWidth.addEventListener("input", updatePageDirection);
el.pageHeight.addEventListener("input", updatePageDirection);
for (const radio of [el.pagePortrait, el.pageLandscape]) {
radio.addEventListener("change", () => {
[el.pageWidth.value, el.pageHeight.value] = [el.pageHeight.value, el.pageWidth.value];
updatePageDirection();
});
}
el.pageForm.addEventListener("submit", async (event) => {
event.preventDefault();
el.pageStatus.textContent = "";
if (await changeDocument("ofdgoBatchPages", null, "resize", selectedPageRange(), Number(el.pageWidth.value), Number(el.pageHeight.value))) {
el.pagePanel.close();
}
});
el.ofdButton.addEventListener("click", openOFDFile);
batchElements.Button.addEventListener("click", () => { updateBatchControls(); batchElements.Panel.showModal(); });
batchElements.Add.addEventListener("click", () => batchElements.Input.click());
batchElements.Input.addEventListener("change", () => { addBatchFiles(batchElements.Input.files); batchElements.Input.value = ""; });
batchElements.Clear.addEventListener("click", () => { batch.items = []; renderBatchList(); batchElements.Status.textContent = ""; batchElements.Progress.value = 0; });
batchElements.Format.addEventListener("change", updateBatchControls);
batchElements.Close.addEventListener("click", () => batchElements.Panel.close());
batchElements.Cancel.addEventListener("click", cancelBatch);
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("drop", event => { event.preventDefault(); event.stopPropagation(); if (!batch.running) addBatchFiles(event.dataTransfer.files); });
batchElements.Destination.querySelector('[value="directory"]').disabled = !window.showDirectoryPicker;
batchElements.Destination.value = window.showDirectoryPicker ? "directory" : "archive";
fontBindingElements.Cancel.addEventListener("click", () => fontBindingElements.Panel.close());
fontBindingElements.Panel.addEventListener("close", () => { fontBinding.font = null; fontBinding.choices = []; fontBinding.sequence++; fontBindingElements.File.value = ""; });
fontBindingElements.Source.addEventListener("change", () => {
if (fontBindingElements.Source.value === "file") { fontBindingElements.Source.value = ""; fontBindingElements.File.click(); }
});
fontBindingElements.File.addEventListener("change", async () => {
const file = fontBindingElements.File.files[0], openSeq = state.openSeq;
if (!file) return;
const sequence = ++fontBinding.sequence;
fontBindingElements.File.value = "";
try {
const data = new Uint8Array(await file.arrayBuffer());
const faces = await callWASM("ofdgoFontFaces", data);
if (sequence !== fontBinding.sequence || openSeq !== state.openSeq) return;
for (const face of faces) {
const value = String(fontBinding.choices.length);
fontBinding.choices.push({ data, index: face.index });
fontBindingElements.Source.add(new Option(face.fullName || face.family || file.name, value));
if (faces.length === 1) fontBindingElements.Source.value = value;
}
fontBindingElements.Status.textContent = faces.length > 1 ? "请选择字体" : "";
} catch (err) { if (sequence === fontBinding.sequence) fontBindingElements.Status.textContent = err.message; }
});
fontBindingElements.Form.addEventListener("submit", async event => {
event.preventDefault();
const font = fontBinding.font, sequence = fontBinding.sequence, openSeq = state.openSeq;
if (!font || document.body.hasAttribute("aria-busy") || !fontBindingElements.Form.reportValidity()) return;
fontBindingElements.Form.inert = true;
try {
let data = null;
if (!font.embedded) {
const choice = fontBinding.choices[Number(fontBindingElements.Source.value)];
if (!choice) throw new Error("请选择字体");
data = choice.data ? (await callWASM("ofdgoFontFace", choice.data, choice.index || 0)).bytes : await readTextFont(choice);
}
if (sequence !== fontBinding.sequence || openSeq !== state.openSeq) return;
if (await changeDocument("ofdgoChangeFontBinding", null, font.id, font.embedded ? 2 : 1, data)) fontBindingElements.Panel.close();
} catch (err) { if (sequence === fontBinding.sequence) fontBindingElements.Status.textContent = fontBindingError(err.message); }
finally { if (sequence === fontBinding.sequence) fontBindingElements.Form.inert = false; }
});
el.newButton.addEventListener("click", openCreatePanel);
el.editButton.addEventListener("click", toggleEditor);
el.editButton.addEventListener("pointerdown", (event) => {
if (canvasEditor.input) event.preventDefault();
});
el.createCancel.addEventListener("click", () => state.imageImporting ? cancelExport() : el.createPanel.close());
el.createPanel.addEventListener("cancel", event => {
if (state.imageImporting) { event.preventDefault(); cancelExport(); }
});
el.createPanel.addEventListener("close", () => { el.createImages.value = ""; });
el.createMode.addEventListener("change", updateCreateOptions);
el.createSize.addEventListener("change", updateCreateOptions);
el.createForm.addEventListener("submit", createDocument);
editorClick(el.insertTextButton, toggleTextTool);
editorClick(el.insertImageButton, () => openInsertPanel());
el.insertCancel.addEventListener("click", () => el.insertPanel.close());
el.insertPanel.addEventListener("close", () => { state.insertObject = null; });
el.insertForm.addEventListener("submit", insertObject);
el.textFontAdd.addEventListener("click", () => openFontFile(el.fontInput));
el.saveButton.addEventListener("click", () => exportFile(true, null, "ofd"));
editorClick(el.encryptSaveButton, openEncryptionPanel);
el.signButton.addEventListener("click", () => {
if (!state.doc || document.body.hasAttribute("aria-busy")) return;
el.signForm.reset();
el.signPages.value = state.editing && state.pageSelection.size ? selectedPageRange() : String(state.pageIndex + 1);
el.signStatus.textContent = "";
updateSignPlacement();
el.signPanel.showModal();
});
el.signSeal.addEventListener("change", updateSignPlacement);
el.signPlacement.addEventListener("change", updateSignPlacement);
el.signCancel.addEventListener("click", () => state.signing ? cancelSigning() : el.signPanel.close());
el.signPanel.addEventListener("cancel", event => {
if (state.signing) { event.preventDefault(); cancelSigning(); }
});
el.signPanel.addEventListener("close", () => { if (!el.signPanel.open && !sealPreview) el.signForm.reset(); });
el.signForm.addEventListener("submit", signDocument);
const sealPosition = Object.fromEntries(["Layer", "Stamp", "Image"].map(name => [name, document.querySelector(`#signPosition${name}`)]));
let sealPreview = null;
document.querySelector("#signPick").addEventListener("click", openSealPosition);
el.viewerPanel.addEventListener("pointerdown", beginSealDrag, true);
el.viewerPanel.addEventListener("pointerdown", pickResourceObject, true);
document.addEventListener("keydown", event => {
if (!resourceManager.pick || el.objectPicker.open) return;
if (event.key === "Escape") { event.preventDefault(); event.stopImmediatePropagation(); finishResourcePick(); return; }
if (event.target.closest?.("input, textarea, select")) return;
if (event.key !== "Tab") { event.preventDefault(); event.stopImmediatePropagation(); }
}, true);
el.viewerPanel.addEventListener("pointermove", moveSealDrag, true);
el.viewerPanel.addEventListener("pointerup", confirmSealPosition, true);
el.viewerPanel.addEventListener("pointercancel", () => { if (sealPreview) finishSealPosition(); }, true);
el.verifyButton.addEventListener("click", () => {
if (!state.doc || document.body.hasAttribute("aria-busy")) return;
el.verifyForm.reset();
el.verifyStatus.textContent = "";
el.verifyPanel.showModal();
});
el.verifyCancel.addEventListener("click", () => el.verifyPanel.close());
el.verifyPanel.addEventListener("close", () => { if (!el.verifyPanel.open) el.verifyForm.reset(); });
el.verifyForm.addEventListener("submit", verifyDocument);
el.credentialsCancel.addEventListener("click", () => finishCredentials(null));
el.credentialsPanel.addEventListener("cancel", event => { event.preventDefault(); finishCredentials(null); });
el.credentialsPanel.addEventListener("close", () => { if (!el.credentialsPanel.open) finishCredentials(null); });
el.credentialsType.addEventListener("change", updateCredentialsType);
el.credentialsForm.addEventListener("submit", async event => {
event.preventDefault();
if (!credentialRequest) return;
if (el.credentialsType.value === "certificate") {
const request = credentialRequest;
let key;
const keyPassword = new TextEncoder().encode(el.credentialsKeyPassword.value);
el.credentialsKeyPassword.value = "";
try {
key = (await securityFiles(el.credentialsKey))[0];
const certificate = (await securityFiles(el.credentialsCertificate))[0];
if (credentialRequest !== request) { key?.fill(0); keyPassword.fill(0); return; }
if (!key || !certificate) { el.credentialsStatus.textContent = "请选择证书和私钥"; key?.fill(0); keyPassword.fill(0); return; }
finishCredentials({password:new Uint8Array(), key, keyPassword, certificate, userName:el.credentialsUser.value.trim()});
} catch {
key?.fill(0);
keyPassword.fill(0);
if (credentialRequest === request) el.credentialsStatus.textContent = "文件读取失败";
}
return;
}
if (!el.credentialsPassword.value) return;
finishCredentials({ password: new TextEncoder().encode(el.credentialsPassword.value), userName: el.credentialsUser.value.trim() });
});
el.togglePagesButton.addEventListener("click", () => toggleSidebar("pages"));
el.toggleMetaButton.addEventListener("click", () => toggleSidebar("meta"));
el.pagesTab.addEventListener("click", () => showNavigation(el.pagesTab));
el.annotationsTab.addEventListener("click", () => showNavigation(el.annotationsTab));
el.annotationsFilter.addEventListener("input", renderAnnotationSummary);
el.annotationsKind.addEventListener("change", renderAnnotationSummary);
el.versionExport.addEventListener("click", exportManagedVersion);
editorClick(settingsElements.Button, openDocumentSettings);
settingsElements.Kind.addEventListener("change", () => { settings.selected = -1; loadDocumentSettings(); });
settingsElements.Form.addEventListener("submit", event => event.preventDefault());
settingsElements.Panel.addEventListener("cancel", event => { if (settings.busy) event.preventDefault(); });
settingsElements.Panel.addEventListener("close", () => { settings.sequence++; });
el.outlinesTab.addEventListener("click", () => showNavigation(el.outlinesTab));
el.searchTab.addEventListener("click", focusSearch);
el.navigationTabs.addEventListener("keydown", (event) => {
if (event.isComposing || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
return;
}
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
event.preventDefault();
const tabs = [el.pagesTab, el.outlinesTab, el.annotationsTab, el.searchTab].filter((tab) => !tab.hidden);
const current = tabs.indexOf(event.target);
const index = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1
: (current + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
showNavigation(tabs[index]);
tabs[index].focus({ preventScroll: true });
}
});
el.searchForm.addEventListener("submit", (event) => {
event.preventDefault();
searchDocument();
});
el.searchInput.addEventListener("input", () => resetSearch(false));
el.searchPrev.addEventListener("click", () => selectSearchMatch(Math.max(0, state.searchIndex) - 1));
el.searchNext.addEventListener("click", () => selectSearchMatch(state.searchIndex + 1));
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("keydown", event => {
if (event.key === "Tab") el.viewerPanel.classList.add("keyboard-focus");
}, true);
document.addEventListener("pointerdown", () => el.viewerPanel.classList.remove("keyboard-focus"), true);
el.fontAddButton.addEventListener("click", () => openFontFile(el.fontInput));
el.fontDirectoryButton.addEventListener("click", () => openFontFile(el.fontDirectoryInput));
el.localFontButton.addEventListener("click", loadLocalFonts);
el.ofdInput.addEventListener("change", () => openOFD(el.ofdInput.files[0]));
el.fontInput.addEventListener("change", openSelectedFonts);
el.fontDirectoryInput.addEventListener("change", openSelectedFonts);
el.prevButton.addEventListener("click", () => renderPage(state.pageIndex - 1));
el.imageDPI.addEventListener("change", () => changeDisplayMode());
el.renderVectorButton.addEventListener("click", () => changeDisplayMode("svg"));
el.renderRasterButton.addEventListener("click", () => changeDisplayMode("raster"));
el.nextButton.addEventListener("click", () => renderPage(state.pageIndex + 1));
el.zoomOutButton.addEventListener("click", () => setScale(state.scale - 0.1));
el.zoomInButton.addEventListener("click", () => setScale(state.scale + 0.1));
el.fitButton.addEventListener("click", fitWidth);
el.fitHeightButton.addEventListener("click", fitHeight);
el.rotateButton.addEventListener("click", rotatePages);
el.panButton.addEventListener("click", togglePan);
el.continuousButton.addEventListener("click", toggleContinuous);
el.annotationButton.addEventListener("click", toggleAnnotations);
document.addEventListener("copy", copySelection);
document.addEventListener("cut", copySelection);
el.viewerPanel.addEventListener("paste", pasteEditorContent);
document.addEventListener("selectionchange", syncSelection);
el.exportFormat.addEventListener("change", () => updateDPIControl());
el.outputCompression.addEventListener("change", () => {
el.compressionValue.textContent = el.outputCompression.selectedOptions[0].textContent;
el.compressionValue.dataset.short = ["原", "无", "轻", "均", "强"][Number(el.outputCompression.value)];
});
el.exportPageButton.addEventListener("click", () => exportFile(false));
el.exportButton.addEventListener("click", () => openExportPanel());
el.exportCancel.addEventListener("click", () => el.exportPanel.close());
el.encryptionPanel.addEventListener("close", () => { if (!el.encryptionPanel.open) clearEncryptionForm(); });
el.encryptionPanel.addEventListener("cancel", event => { if (el.encryptionForm.inert) event.preventDefault(); });
el.encryptionCancel.addEventListener("click", () => el.encryptionPanel.close());
el.encryptionType.addEventListener("change", () => {
state.encryptionChanged = false;
updateEncryptionType();
});
el.encryptionChange.addEventListener("click", () => {
state.encryptionChanged = true;
updateEncryptionType();
(el.encryptionType.value === "password" ? el.encryptionPassword : el.encryptionCertificates).focus();
});
el.encryptionForm.addEventListener("submit", applyEncryption);
el.cancelExportButton.addEventListener("click", cancelExport);
el.exportPanel.addEventListener("pointerdown", (event) => {
state.exportBackdrop = event.target === el.exportPanel;
});
el.exportPanel.addEventListener("click", (event) => {
if (state.exportBackdrop && event.target === el.exportPanel) {
el.exportPanel.close();
}
});
el.exportAll.addEventListener("change", updateExportRange);
el.exportSpecified.addEventListener("change", () => {
updateExportRange();
el.exportRange.focus();
});
el.exportRange.addEventListener("input", updateExportRange);
el.exportForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!el.exportSubmit.disabled) {
return exportFile(true, state.exportPages);
}
});
el.refreshAppButton.addEventListener("click", refreshApplication);
el.pageInput.addEventListener("change", () => {
const page = Number.parseInt(el.pageInput.value, 10);
if (Number.isFinite(page)) {
renderPage(page - 1);
}
});
window.addEventListener("resize", resizeViewer);
window.addEventListener("blur", () => endPan());
COMPACT_LAYOUT.addEventListener("change", syncLayoutMode);
el.viewerPanel.addEventListener("scroll", () => {
schedulePageSync();
});
el.navigationContent.addEventListener("scroll", () => {
if (state.thumbnailFrame) return;
state.thumbnailFrame = requestAnimationFrame(() => {
state.thumbnailFrame = 0;
syncThumbnailWindow();
});
});
for (const [viewport, current] of [[el.viewerPanel, () => state.pageWindow], [el.navigationContent, () => el.pageList.hidden ? null : state.thumbnailWindow]]) {
viewport.addEventListener("wheel", event => {
const view = current();
if (!view || view.total <= SCROLL_SEGMENT_SIZE || event.ctrlKey || event.shiftKey || !event.deltaY) return;
event.preventDefault();
view.relativeScroll = false;
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewport.clientHeight : 1;
scrollPageWindowBy(view, event.deltaY * unit);
viewport.scrollLeft += event.deltaX * unit;
}, { passive: false });
viewport.addEventListener("pointerdown", event => {
const view = current();
if (view) view.relativeScroll = event.pointerType === "touch" || state.editing && !!event.target.closest(".page-shell");
});
}
el.viewerPanel.addEventListener("pointerdown", startPan);
el.viewerPanel.addEventListener("pointermove", movePan);
el.viewerPanel.addEventListener("pointerup", endPan);
el.viewerPanel.addEventListener("pointercancel", endPan);
el.viewerPanel.addEventListener("lostpointercapture", endPan);
el.viewerPanel.addEventListener("click", () => {
if (COMPACT_LAYOUT.matches && (state.showPages || state.showMeta)) {
state.showPages = false;
state.showMeta = false;
updateSidebarState();
}
});
el.viewerPanel.addEventListener("dblclick", openOFDFromViewer);
document.addEventListener("dragover", (event) => {
if (event.dataTransfer.types.includes("Files")) {
event.preventDefault();
event.dataTransfer.dropEffect = document.body.hasAttribute("aria-busy") ? "none" : "copy";
}
});
document.addEventListener("drop", openOFDFromDrop);
el.fontList.addEventListener("focusout", () => {
if (state.fontSyncPending) {
window.setTimeout(syncUserFonts, 0);
}
});
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
scheduleFontSync();
}
});
el.fontDirectoryButton.disabled = !("webkitdirectory" in el.fontDirectoryInput);
updateSidebarState();
boot();
function handleKeyDown(event) {
if (sealPreview) {
if (event.key === "Escape") { event.preventDefault(); finishSealPosition(); }
return;
}
if (event.defaultPrevented || event.isComposing || event.altKey) {
return;
}
const key = event.key;
const target = event.target;
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && key.toLowerCase() === "s" && state.doc
&& (state.editorInfo || state.ofdBytes)) {
event.preventDefault();
if (!document.body.hasAttribute("aria-busy") && !formDialogOpen()) exportFile(true, null, "ofd");
return;
}
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && key.toLowerCase() === "a" && state.doc
&& el.viewerPanel.contains(target) && !target.closest("input, textarea, select, [contenteditable]")) {
event.preventDefault();
if (!document.body.hasAttribute("aria-busy") && !formDialogOpen()) {
selectDocumentText();
}
return;
}
if (document.body.hasAttribute("aria-busy") || formDialogOpen()) {
return;
}
if (state.pageWindow && (!event.shiftKey || key === " ") && el.viewerPanel.contains(target)
&& !target.closest("input, textarea, select, button, a, [contenteditable]")) {
const view = state.pageWindow;
const delta = { ArrowUp: -40, ArrowDown: 40, PageUp: -el.viewerPanel.clientHeight * 0.9, PageDown: el.viewerPanel.clientHeight * 0.9,
" ": el.viewerPanel.clientHeight * 0.9 * (event.shiftKey ? -1 : 1) }[key];
if (key === "Home" || key === "End") {
event.preventDefault();
view.relativeScroll = false;
syncPageWindow(view, key === "Home" ? -windowInset(view) : view.total);
syncCurrentPageFromScroll();
return;
}
if (delta !== undefined && !event.ctrlKey && !event.metaKey && view.total > SCROLL_SEGMENT_SIZE) {
event.preventDefault();
scrollPageWindowBy(view, delta);
return;
}
}
if (event.ctrlKey || event.metaKey) {
if (state.editing && !target.closest("input, textarea, select, [contenteditable]")
&& (key.toLowerCase() === "z" || (!event.shiftKey && key.toLowerCase() === "y"))) {
event.preventDefault();
(key.toLowerCase() === "y" || event.shiftKey ? el.redoButton : el.undoButton).click();
} else if (!event.shiftKey && key.toLowerCase() === "o") {
event.preventDefault();
openOFDFile();
} else if (!event.shiftKey && key.toLowerCase() === "f" && state.doc) {
event.preventDefault();
focusSearch();
}
return;
}
if (!state.doc) {
return;
}
if (key === "Escape" && !event.shiftKey && state.documentSelection && el.viewerPanel.contains(target)
&& !target.closest("input, textarea, select, [contenteditable]")) {
event.preventDefault();
document.getSelection().removeAllRanges();
syncSelection();
return;
}
if (key === "F3") {
event.preventDefault();
if (state.searchMatches.length) {
selectSearchMatch(event.shiftKey ? Math.max(0, state.searchIndex) - 1 : state.searchIndex + 1);
} else {
focusSearch();
}
return;
}
if (key === "Escape" && !event.shiftKey && !el.searchPanel.hidden && (el.pageListPanel.contains(target) || el.viewerPanel.contains(target))) {
event.preventDefault();
resetSearch();
showNavigation(el.pagesTab);
if (COMPACT_LAYOUT.matches && state.showPages) {
state.showPages = false;
updateSidebarState();
}
canvasEditor.focus();
return;
}
if (key === "Enter" && target === el.pageInput && !event.shiftKey) {
event.preventDefault();
if (!event.repeat) el.pageInput.dispatchEvent(new Event("change"));
return;
}
if (key === "Enter" && target === el.searchInput && event.shiftKey && state.searchMatches.length) {
event.preventDefault();
selectSearchMatch(Math.max(0, state.searchIndex) - 1);
return;
}
if (!el.viewerPanel.contains(target) || target.closest("input, textarea, select, button, a, [contenteditable]")) {
return;
}
let button;
if (key === "+" || (key === "=" && !event.shiftKey)) {
button = el.zoomInButton;
} else if (key === "-" && !event.shiftKey) {
button = el.zoomOutButton;
} else if (!event.shiftKey && (key === "ArrowLeft" || key === "ArrowRight")) {
if (document.getSelection().isCollapsed && el.viewerPanel.scrollWidth <= el.viewerPanel.clientWidth) {
button = key === "ArrowLeft" ? el.prevButton : el.nextButton;
}
}
if (button) {
event.preventDefault();
button.click();
}
}
function formDialogOpen() {
return Boolean(sealPreview || resourceManager.pick) || fontBindingElements.Panel.open || el.signPanel.open || el.verifyPanel.open || el.credentialsPanel.open || el.encryptionPanel.open || batchElements.Panel.open || el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open || el.documentPanel.open
|| resourceElements.Panel.open || el.structureDialog.open || el.versionPanel.open || el.batchPagesPanel.open || el.objectStylePanel.open || el.sourceTextPanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open || el.annotationCreate.open || el.objectPicker.open;
}
function setObjectDimension(input, value) {
input.value = String(Number(value.toFixed(2)));
input.defaultValue = input.value;
input.dataset.precise = String(value);
}
function objectDimension(input) {
return Number(input.value === input.defaultValue ? input.dataset.precise : input.value);
}
function openObjectBounds() {
const item = canvasEditor.selected;
let box = item.geometry || selectionBounds((item.items || [item]).map(member => member.bounds || member));
if (item.oriented) {
const frame = item.oriented, [a, b, c, d, e, f] = frame.matrix;
box = { x: a * frame.box.x + c * frame.box.y + e, y: b * frame.box.x + d * frame.box.y + f,
width: frame.box.width * Math.hypot(a, b), height: frame.box.height * Math.hypot(c, d) };
}
state.boundsOriginal = { x: box.x, y: box.y, width: Math.abs(box.width), height: Math.abs(box.height) };
for (const [input, key] of [[el.objectX,"x"], [el.objectY,"y"], [el.objectWidth,"width"], [el.objectHeight,"height"]]) setObjectDimension(input, state.boundsOriginal[key]);
el.objectAspect.checked = true;
el.objectAspect.disabled = !item.shape && !item.oriented && !canEditObject(item, "stretch") || lineShape(item.shape) && (!box.width || !box.height);
el.objectWidth.disabled = lineShape(item.shape) && !box.width;
el.objectHeight.disabled = lineShape(item.shape) && !box.height;
el.objectBoundsStatus.textContent = "";
el.objectBoundsPanel.showModal();
}
function canCopyStyle(item) {
if (item?.type === "Annotation") return false;
if (!item?.scoped) return canEditObject(item, "update");
if (item.type === "TextObject") return canEditObject(item, "replaceFont") && canEditObject(item, "paint");
if (item.type === "PathObject") return canEditObject(item, "transform") && canEditObject(item, "paint");
return item.type === "ImageObject" && canEditObject(item, "transform");
}
function openObjectStyle() {
const items = canvasEditor.items();
if (!items.length) return;
const ordered = [...items].sort((a, b) => a.position - b.position);
el.groupObjectsButton.disabled = items.length < 2 || !ordered.every((item, index) => canEditObject(item, "copy") && canEditObject(item, "order") && item.container === ordered[0].container && item.position === ordered[0].position + index);
el.ungroupObjectButton.disabled = items.length !== 1 || !canEditObject(items[0], "ungroup");
el.copyStyleButton.disabled = items.length !== 1 || !canCopyStyle(items[0]);
el.pasteStyleButton.disabled = !state.styleClipboard || !items.every(item => item.type === state.styleClipboard && canCopyStyle(item));
const shared = (key, fallback) => items.every(item => (item[key] ?? fallback) === (items[0][key] ?? fallback)) ? items[0][key] ?? fallback : null;
el.objectLinkFields.hidden = items.length !== 1 || items[0].scoped;
const link = items[0].link ? JSON.parse(items[0].link) : null;
const targetPage = link?.Goto?.Dest ? pageIndexByID(state.doc.pages, link.Goto.Dest.PageID) : -1;
el.objectLinkKind.value = items[0].linkCount > 1 ? "keep" : link?.URI ? "uri" : targetPage >= 0 ? "page" : link ? "keep" : "none";
el.objectLinkKind.disabled = items[0].linkCount > 1;
el.objectLinkAddress.value = link?.URI?.URI || "";
el.objectLinkPage.value = String(targetPage >= 0 ? targetPage + 1 : 1);
el.objectLinkPage.max = String(state.doc?.pages.length || 1);
state.objectLinkOriginal = { kind: el.objectLinkKind.value, address: el.objectLinkAddress.value, page: el.objectLinkPage.value, link };
updateObjectLinkFields();
el.objectBorderFields.hidden = items.length !== 1 || items[0].scoped || items[0].type !== "ImageObject";
const border = items[0].borderStyle ? JSON.parse(items[0].borderStyle) : null;
el.borderEnabled.checked = Boolean(border);
el.borderWidth.value = String(border?.LineWidth ?? .353);
el.borderHorizontal.value = String(border?.HorizonalCornerRadius ?? 0);
el.borderVertical.value = String(border?.VerticalCornerRadius ?? 0);
el.borderColor.value = items[0].borderColor || "#000000";
el.borderColor.disabled = Boolean(border && !items[0].borderColor);
el.borderColor.hidden = el.borderColor.disabled;
el.borderColorOriginal.hidden = !el.borderColor.disabled;
state.borderOriginal = {
Enabled: el.borderEnabled.checked, LineWidth: el.borderWidth.value,
HorizontalRadius: el.borderHorizontal.value, VerticalRadius: el.borderVertical.value,
Color: el.borderColor.value
};
el.objectGradientFields.hidden = items.length !== 1 || items[0].scoped || items[0].type !== "PathObject" || !items[0].paintSize || !canEditObject(items[0], "paint");
state.gradientForms = {};
for (const target of ["fill", "stroke"]) {
const gradient = items[0][`${target}Gradient`], base = gradient ? JSON.parse(gradient.paint) : null;
const node = base?.AxialShd || base?.RadialShd;
const start = node?.StartPoint.split(/\s+/).map(Number), end = node?.EndPoint.split(/\s+/).map(Number);
const form = {
kind: base?.AxialShd ? "linear" : base?.RadialShd ? "radial" : "keep",
angle: String(base?.AxialShd ? Math.atan2(end[1] - start[1], end[0] - start[0]) * 180 / Math.PI : 0),
stops: gradient ? gradient.stops.map((stop, i) => ({ position: String(stop.position), color: stop.color, transparency: String(stop.transparency), source: node.Segment[i] }))
: [{ position: "0", color: "#000000", transparency: "0" }, { position: "100", color: "#ffffff", transparency: "0" }]
};
state.gradientForms[target] = { base, form, original: JSON.stringify(form) };
}
el.gradientTarget.value = "fill";
loadGradientForm("fill");
state.patternForms = {};
for (const target of ["fill", "stroke"]) {
const pattern = items.length === 1 && !items[0].scoped ? items[0][`${target}Pattern`] : null;
if (pattern) state.patternForms[target] = Object.fromEntries(Object.entries(pattern).map(([key, value]) => [key, String(value)]));
}
state.patternOriginal = JSON.stringify(state.patternForms);
el.objectPatternFields.hidden = !Object.keys(state.patternForms).length;
el.patternTarget.disabled = Object.keys(state.patternForms).length < 2;
el.patternTarget.value = state.patternForms.fill ? "fill" : "stroke";
loadPatternForm(el.patternTarget.value);
const alpha = items.some(item => item.alphaMixed) ? null : shared("alpha", 255), dash = shared("dashPattern", "");
el.objectOpacity.value = alpha === null ? "" : String(Math.round((1 - alpha / 255) * 10000) / 100);
el.objectOpacity.placeholder = alpha === null ? "混合" : "";
el.objectStrokeFields.hidden = !items.every(item => item.type === "PathObject" && (!item.scoped || canEditObject(item, "paint")));
el.objectDash.value = dash === null ? "mixed" : dash === "" ? "solid" : "custom";
el.objectDashPattern.value = dash || "";
el.objectDashRow.hidden = el.objectDash.value !== "custom";
el.objectDashPattern.required = !el.objectStrokeFields.hidden && !el.objectDashRow.hidden;
el.objectDashOffset.value = shared("dashOffset", 0) ?? "";
el.objectCap.value = shared("cap", "") || (shared("cap", "") === null ? "mixed" : "Butt");
el.objectJoin.value = shared("join", "") || (shared("join", "") === null ? "mixed" : "Miter");
state.styleOriginal = Object.fromEntries(["objectOpacity", "objectDash", "objectDashPattern", "objectDashOffset", "objectCap", "objectJoin"].map(key => [key, el[key].value]));
el.objectStyleStatus.textContent = "";
el.objectStylePanel.showModal();
}
function readObjectStyle() {
const style = {}, changed = key => el[key].value !== state.styleOriginal[key];
if (!el.objectLinkFields.hidden) {
const original = state.objectLinkOriginal, kind = el.objectLinkKind.value;
if (kind !== "keep" && (kind !== original.kind || kind === "uri" && el.objectLinkAddress.value !== original.address || kind === "page" && el.objectLinkPage.value !== original.page)) {
style.link = kind === "none" ? null : JSON.stringify(kind === "uri"
? { URI: { ...original.link?.URI, URI: el.objectLinkAddress.value.trim() } }
: { Dest: { ...(original.link?.Goto?.Dest || { Type: "Fit" }), PageID: state.doc.pages[Number(el.objectLinkPage.value) - 1].id } });
}
}
if (!el.objectBorderFields.hidden) {
const border = {};
if (el.borderEnabled.checked !== state.borderOriginal.Enabled) border.Enabled = el.borderEnabled.checked;
if (el.borderEnabled.checked) {
for (const [input, key] of [[el.borderWidth, "LineWidth"], [el.borderHorizontal, "HorizontalRadius"], [el.borderVertical, "VerticalRadius"]]) {
if (input.value !== state.borderOriginal[key] || !state.borderOriginal.Enabled) border[key] = Number(input.value);
}
if (!el.borderColor.disabled && (el.borderColor.value !== state.borderOriginal.Color || !state.borderOriginal.Enabled)) {
border.Color = { Value: el.borderColor.value };
}
}
if (Object.keys(border).length) style.borderStyle = JSON.stringify(border);
}
if (!el.objectGradientFields.hidden) {
state.gradientForms[state.gradientTarget].form = gradientForm();
for (const target of ["fill", "stroke"]) {
const { base, form, original } = state.gradientForms[target];
if (form.kind === "keep" || JSON.stringify(form) === original) continue;
const [width, height] = canvasEditor.selected.paintSize;
const previous = JSON.parse(original);
const Segment = form.stops.map(stop => {
const old = previous.stops.find(entry => entry.source && JSON.stringify(entry.source) === JSON.stringify(stop.source));
const Color = old && old.color === stop.color ? { ...stop.source.Color } : { Value: stop.color };
if (!old || old.transparency !== stop.transparency || old.color !== stop.color) Color.Alpha = Math.round(255 * (1 - Number(stop.transparency) / 100));
return { Position: Number(stop.position) / 100, Color };
}).sort((a, b) => a.Position - b.Position);
const angle = Number(form.angle) * Math.PI / 180, x = Math.cos(angle), y = Math.sin(angle);
const length = Math.abs(width * x) + Math.abs(height * y);
let paint = form.kind === "linear"
? { AxialShd: { StartPoint: `${width / 2 - x * length / 2} ${height / 2 - y * length / 2}`, EndPoint: `${width / 2 + x * length / 2} ${height / 2 + y * length / 2}`, Extend: "3", Segment } }
: { RadialShd: { StartPoint: `${width / 2} ${height / 2}`, EndPoint: `${width / 2} ${height / 2}`, EndRadius: Math.max(width, height) / 2, Extend: "3", Segment } };
if (base && form.kind === previous.kind) {
paint = JSON.parse(JSON.stringify(base));
const node = paint.AxialShd || paint.RadialShd;
node.Segment = Segment;
if (paint.AxialShd && form.angle !== previous.angle) {
const start = node.StartPoint.split(/\s+/).map(Number), end = node.EndPoint.split(/\s+/).map(Number);
const half = Math.hypot(end[0] - start[0], end[1] - start[1]) / 2, cx = (start[0] + end[0]) / 2, cy = (start[1] + end[1]) / 2;
node.StartPoint = `${cx - x * half} ${cy - y * half}`;
node.EndPoint = `${cx + x * half} ${cy + y * half}`;
}
}
style[`${target}Paint`] = JSON.stringify(paint);
style[target] = true;
}
}
if (!el.objectPatternFields.hidden) {
state.patternForms[state.patternTarget] = patternForm();
const original = JSON.parse(state.patternOriginal);
for (const [target, form] of Object.entries(state.patternForms)) {
const pattern = {};
for (const [key, field] of [["width", "Width"], ["height", "Height"], ["xStep", "XStep"], ["yStep", "YStep"], ["ctm", "CTM"]]) {
if (form[key] !== original[target][key]) pattern[field] = key === "ctm" ? form[key].trim() : Number(form[key]);
}
if (Object.keys(pattern).length) style[`${target}PatternStyle`] = JSON.stringify(pattern);
}
}
if (changed("objectOpacity") && el.objectOpacity.value !== "") style.alpha = Math.round((1 - Number(el.objectOpacity.value) / 100) * 255);
if (!el.objectStrokeFields.hidden) {
if (changed("objectDash") || changed("objectDashPattern")) {
const patterns = { solid: "", dash: "3 2", dot: "0.3 1.5", dashdot: "3 1.5 0.3 1.5", custom: el.objectDashPattern.value.trim() };
if (el.objectDash.value in patterns) style.dashPattern = patterns[el.objectDash.value];
}
if (changed("objectDashOffset") && el.objectDashOffset.value !== "") style.dashOffset = Number(el.objectDashOffset.value);
if (changed("objectCap") && el.objectCap.value !== "mixed") style.cap = el.objectCap.value;
if (changed("objectJoin") && el.objectJoin.value !== "mixed") style.join = el.objectJoin.value;
}
return style;
}
function updateObjectLinkFields() {
const kind = el.objectLinkKind.value, visible = !el.objectLinkFields.hidden;
el.objectLinkAddressRow.hidden = !visible || kind !== "uri";
el.objectLinkPageRow.hidden = !visible || kind !== "page";
el.objectLinkAddress.disabled = el.objectLinkAddressRow.hidden;
el.objectLinkPage.disabled = el.objectLinkPageRow.hidden;
el.objectLinkAddress.required = visible && kind === "uri";
el.objectLinkPage.required = visible && kind === "page";
}
function gradientForm() {
return {
kind: el.gradientKind.value, angle: el.gradientAngle.value,
stops: [...el.gradientStops.children].map(row => ({ position: row.children[1].value, color: row.children[0].value, transparency: row.children[2].value, source: row.paintSource }))
};
}
function loadGradientForm(target) {
state.gradientTarget = target;
const form = state.gradientForms[target].form;
el.gradientKind.value = form.kind;
el.gradientAngle.value = form.angle;
el.gradientControls.hidden = form.kind === "keep";
el.gradientAngle.disabled = form.kind !== "linear";
el.gradientStops.replaceChildren();
for (const stop of form.stops) addGradientStop(stop.position, stop.color, stop.transparency, stop.source);
}
function patternForm() {
return { width: el.patternWidth.value, height: el.patternHeight.value, xStep: el.patternXStep.value, yStep: el.patternYStep.value, ctm: el.patternCTM.value };
}
function loadPatternForm(target) {
state.patternTarget = target;
const pattern = state.patternForms[target];
for (const [input, key] of [[el.patternWidth, "width"], [el.patternHeight, "height"], [el.patternXStep, "xStep"], [el.patternYStep, "yStep"], [el.patternCTM, "ctm"]]) {
input.value = pattern?.[key] ?? "";
input.disabled = !pattern;
}
}
function addGradientStop(position, color, transparency, source) {
const row = document.createElement("div");
row.paintSource = source;
row.className = "gradient-stop-row";
for (const [type, value, label] of [["color", color, "色标颜色"], ["number", position, "色标位置"], ["number", transparency, "色标透明度"]]) {
const input = document.createElement("input");
input.type = type;
input.value = value;
input.disabled = el.gradientKind.value === "keep";
input.setAttribute("aria-label", label);
input.title = label;
if (type === "number") { input.min = "0"; input.max = "100"; input.step = "any"; input.required = true; }
row.append(input);
}
const remove = document.createElement("button");
remove.type = "button";
remove.className = "small-button";
remove.textContent = "×";
remove.title = "删除色标";
remove.setAttribute("aria-label", "删除色标");
remove.addEventListener("click", () => row.remove());
row.append(remove);
el.gradientStops.append(row);
}
function openOutlinePanel(action) {
state.outlineAction = action;
let outline = null, items = state.doc.outlines || [];
for (const index of state.outlineSelection || []) { outline = items[index]; items = outline.children || []; }
el.outlineTitle.value = action === "add" ? "" : outline.title;
el.outlinePage.value = action === "add" ? String(state.pageIndex + 1) : outline.page || "";
el.outlinePage.max = String(state.doc.pageCount);
el.outlineParent.replaceChildren();
const addOption = (path, title) => {
const option = document.createElement("option");
option.value = JSON.stringify(path);
option.textContent = title;
option.title = title;
el.outlineParent.append(option);
};
addOption([], "无");
const walk = (items, path = []) => items.forEach((item, index) => {
const next = [...path, index];
if (action === "move" && state.outlineSelection.every((part, i) => next[i] === part)) return;
addOption(next, `${" ".repeat(path.length)}${item.title}`);
walk(item.children || [], next);
});
walk(state.doc.outlines || []);
el.outlineParent.value = JSON.stringify(action === "add" ? state.outlineSelection || [] : state.outlineSelection.slice(0, -1));
el.outlineParent.disabled = action !== "add" && action !== "move";
el.outlineTitle.disabled = el.outlinePage.disabled = action === "delete" || action === "move";
el.outlinePositionRow.hidden = action !== "move";
el.outlinePosition.disabled = action !== "move";
updateOutlinePosition();
el.outlineSubmit.textContent = { add: "新增", update: "确定", delete: "删除", move: "确定" }[action];
el.outlineStatus.textContent = action === "delete" && outline.children?.length ? "同时删除子目录" : "";
el.outlinePanel.showModal();
}
function outlineChildren(outlines, path) {
for (const index of path) outlines = outlines[index].children || [];
return outlines;
}
function updateOutlinePosition() {
if (state.outlineAction !== "move") return;
const parent = JSON.parse(el.outlineParent.value);
const same = JSON.stringify(parent) === JSON.stringify(state.outlineSelection.slice(0, -1));
const count = outlineChildren(state.doc.outlines, parent).length + (same ? 0 : 1);
el.outlinePosition.max = String(count);
el.outlinePosition.value = String(same ? state.outlineSelection.at(-1) + 1 : count);
}
function remapOutlineExpansion(name, args, next) {
const roots = state.doc.outlines || [], expanded = new Map();
const walk = (items, visit, path = []) => items.forEach((item, index) => {
const current = [...path, index];
visit(item, JSON.stringify(current));
walk(item.children || [], visit, current);
});
walk(roots, (item, key) => expanded.set(item, state.outlineExpanded.get(key) ?? item.expanded));
const moving = name === "ofdgoMoveOutline", action = moving ? "move" : args[0], source = moving ? args[0] : args[1];
let item;
if (action === "move" || action === "delete") item = outlineChildren(roots, source.slice(0, -1)).splice(source.at(-1), 1)[0];
if (action === "move" || action === "add") {
let children = roots;
for (const index of next.slice(0, -1)) {
expanded.set(children[index], true);
children = children[index].children ||= [];
}
children.splice(next.at(-1), 0, item || {});
}
state.outlineExpanded.clear();
walk(roots, (item, key) => state.outlineExpanded.set(key, Boolean(expanded.get(item))));
}
function openImportPanel() {
if (!state.editing || document.body.hasAttribute("aria-busy")) return;
state.importPageCount = 0;
el.importForm.reset();
el.importStatus.textContent = "";
el.importSubmit.disabled = true;
el.importPagesRow.hidden = true;
el.importPages.disabled = true;
el.importPages.required = false;
el.importPanel.showModal();
}
async function loadImportFile() {
const file = el.importFile.files[0];
const openSeq = state.openSeq;
state.importPageCount = 0;
state.importEncrypted = false;
el.importSubmit.disabled = true;
el.importStatus.textContent = "";
setBusy(true);
try {
const data = file ? new Uint8Array(await file.arrayBuffer()) : null;
if (openSeq !== state.openSeq) return;
const info = data ? await openWithCredentials(null, openSeq, data, true) : await callWASM("ofdgoLoadImport", null);
if (openSeq !== state.openSeq || !el.importPanel.open) {
await callWASM("ofdgoLoadImport", null);
return;
}
state.importPageCount = info?.pageCount || 0;
state.importEncrypted = Boolean(info?.encrypted);
el.importPages.title = state.importPageCount ? `页码 1-${state.importPageCount}` : "页码";
el.importStatus.textContent = info?.signed ? "保留签章外观,原签名不再有效" : "";
el.importSubmit.disabled = !state.importPageCount;
} catch (err) {
if (openSeq === state.openSeq && el.importPanel.open) el.importStatus.textContent = err.message;
} finally {
if (openSeq === state.openSeq) setBusy(false);
}
}
function openPagePanel() {
if (!state.editing || document.body.hasAttribute("aria-busy")) {
return;
}
const pages = selectedPageIndexes().map(index => state.doc.pages[index]);
if (!pages.length) return;
for (const [input, key] of [[el.pageWidth, "width"], [el.pageHeight, "height"]]) {
const same = pages.every(page => page[key] === pages[0][key]);
input.value = same ? String(pages[0][key]) : "";
input.placeholder = same ? "" : "混合";
}
el.pageRangeRow.hidden = pages.length === 1;
el.pageRange.textContent = `共${pages.length}页`;
el.pageRange.title = selectedPageRange();
el.pageStatus.textContent = "";
updatePageDirection();
el.pagePanel.showModal();
}
function updatePageDirection() {
const complete = el.pageWidth.value !== "" && el.pageHeight.value !== "";
el.pagePortrait.disabled = el.pageLandscape.disabled = !complete;
const landscape = Number(el.pageWidth.value) > Number(el.pageHeight.value);
el.pageLandscape.checked = complete && landscape;
el.pagePortrait.checked = complete && !landscape;
}
function warnUnsaved(event) {
event.preventDefault();
event.returnValue = "";
}
function setDirty(dirty) {
if (state.dirty === dirty) {
return;
}
state.dirty = dirty;
if (dirty) {
window.addEventListener("beforeunload", warnUnsaved);
} else {
window.removeEventListener("beforeunload", warnUnsaved);
}
}
function discardChanges() {
return !state.dirty || window.confirm("文档尚未保存,放弃更改?");
}
function setEditorInfo(doc) {
state.editorInfo = { revision: doc.revision, canUndo: doc.canUndo, canRedo: doc.canRedo,
pageCapabilities: doc.pageCapabilities, editWarnings: doc.editWarnings || [] };
setDirty(state.packageDirty || state.encryptionDirty || doc.revision !== state.savedRevision);
}
async function openVersionManagement() {
if (!state.doc || !state.ready || state.exporting || document.body.hasAttribute("aria-busy")) return;
state.versionManagement = { id: state.doc.documentVersion || "", entries: null, loading: false, failed: false };
el.versionList.replaceChildren();
el.versionName.value = "";
el.versionPanel.showModal();
await refreshVersionManagement(true);
}
async function refreshVersionManagement(focus = false) {
const manager = state.versionManagement, openSeq = state.openSeq;
if (!manager || !el.versionPanel.open) return;
manager.loading = true;
manager.failed = false;
el.versionStatus.textContent = "正在读取版本";
updateVersionControls();
try {
const entries = await callWASM("ofdgoDocumentVersions");
if (state.versionManagement !== manager || !el.versionPanel.open) return;
if (openSeq !== state.openSeq) return refreshVersionManagement(focus);
manager.entries = entries;
if (!entries.some(entry => entry.id === manager.id)) manager.id = state.doc.documentVersion || "";
el.versionList.replaceChildren(...entries.map(entry => {
const button = document.createElement("button");
button.type = "button";
button.className = "document-entry";
button.setAttribute("role", "option");
button.setAttribute("aria-current", String(entry.id === (state.doc.documentVersion || "")));
const name = document.createElement("span"), marker = document.createElement("span");
name.className = "document-name";
name.textContent = entry.id ? entry.name || `版本 ${entry.index}` : "主文档";
marker.className = "document-pages";
marker.textContent = entry.default ? "默认" : "";
button.title = name.textContent;
button.append(name, marker);
button.addEventListener("click", () => selectManagedVersion(entry.id));
return button;
}));
manager.loading = false;
el.versionStatus.textContent = "";
selectManagedVersion(manager.id, focus);
} catch (err) {
if (state.versionManagement !== manager || !el.versionPanel.open) return;
if (openSeq !== state.openSeq) return refreshVersionManagement(focus);
manager.failed = true;
el.versionStatus.textContent = err.message;
} finally {
if (state.versionManagement === manager && openSeq === state.openSeq) {
manager.loading = false;
updateVersionControls();
}
}
}
function selectManagedVersion(id, focus = false) {
const manager = state.versionManagement;
if (!manager || manager.loading || document.body.hasAttribute("aria-busy")) return;
const index = manager.entries?.findIndex(entry => entry.id === id) ?? -1;
if (index < 0) return;
manager.id = id;
el.versionName.value = manager.entries[index].name;
Array.from(el.versionList.children).forEach((button, position) => {
button.setAttribute("aria-selected", String(position === index));
button.tabIndex = position === index ? 0 : -1;
});
updateVersionControls();
if (focus) el.versionList.children[index]?.focus();
}
async function changeVersion(action) {
const manager = state.versionManagement;
if (!state.ready || state.exporting || !state.editing && action !== "select" || !manager?.entries || manager.loading || manager.failed || document.body.hasAttribute("aria-busy")) return;
const entry = manager.entries.find(entry => entry.id === manager.id);
if (!entry) return;
if (!state.editing) {
if (await switchReadingVersion(entry.id) && state.versionManagement === manager && el.versionPanel.open) {
manager.id = state.doc.documentVersion || "";
await refreshVersionManagement(true);
}
return;
}
if (action === "delete" && (!entry.id || !window.confirm(`删除版本“${entry.name || entry.index}”?`))) return;
el.versionStatus.textContent = "正在处理版本";
if (await changeDocument("ofdgoChangeVersion", null, action, entry.id, el.versionName.value.trim())) {
if (state.versionManagement !== manager || !el.versionPanel.open) return;
if (action === "add" || action === "select") manager.id = state.doc.documentVersion || "";
await refreshVersionManagement(true);
}
}
async function switchReadingVersion(id) {
if (!state.doc || state.editing || !state.ready || state.exporting || document.body.hasAttribute("aria-busy")) return;
if (id === (state.doc.documentVersion || "")) return true;
const dirty = state.dirty;
const before = state.editorInfo ? editorLocation() : null, revision = state.editorInfo?.revision;
const openSeq = ++state.openSeq;
setBusy(true, "正在切换版本");
try {
const doc = await callWASM("ofdgoSelectVersion", id);
if (openSeq !== state.openSeq) return;
state.objectClipboard = state.styleClipboard = null;
resetCompositeScope();
canvasEditor.clear();
canvasEditor.setTool("");
delete state.textDefaults.fontChoice;
updateTextFonts(null, true);
if (state.editorInfo) {
if (!dirty) state.savedRevision = doc.revision;
setEditorInfo(doc);
}
await openDocument({ doc, openSeq, skipAutoFonts: true, resetScroll: true, clearSelection: true,
fitMode: state.fitMode, scale: state.scale, activateActions: true });
if (openSeq !== state.openSeq) return;
if (before) {
rememberEditorView(before, revision);
const view = state.editorViews.get(doc.revision);
if (view) view.reading = true;
}
return true;
} catch (error) {
if (openSeq === state.openSeq) el.versionStatus.textContent = error.message;
} finally {
if (openSeq === state.openSeq) setBusy(false);
}
}
function updateVersionControls() {
const manager = state.versionManagement, busy = document.body.hasAttribute("aria-busy");
const disabled = !state.doc || !state.ready || state.exporting || busy;
const title = state.editing ? "版本管理" : "版本浏览";
el.versionManageButton.hidden = !state.editing && !(state.doc?.versionCount > 0);
el.versionManageButton.title = title;
el.versionManageButton.setAttribute("aria-label", title);
el.versionTitle.textContent = title;
el.versionManageButton.disabled = disabled;
el.versionNotice.textContent = state.editing ? state.editorInfo?.editWarnings?.join(";") || "" : "";
el.versionNotice.hidden = !el.versionNotice.textContent;
el.versionNameRow.hidden = !state.editing;
el.versionActions.classList.toggle("version-readonly", !state.editing);
for (const button of [el.versionAdd, el.versionRename, el.versionDelete, el.versionDefault]) button.hidden = !state.editing;
const entry = manager?.entries?.find(entry => entry.id === manager.id);
const unavailable = disabled || !entry || Boolean(manager?.loading || manager?.failed);
el.versionList.inert = busy || Boolean(manager?.loading);
el.versionName.disabled = el.versionAdd.disabled = unavailable || !state.editing;
el.versionRename.disabled = unavailable || !state.editing || !entry?.id || el.versionName.value.trim() === entry.name;
el.versionDelete.disabled = unavailable || !state.editing || !entry?.id || manager.entries.length < 2;
el.versionDefault.disabled = unavailable || !state.editing || Boolean(entry?.default);
el.versionSelect.disabled = unavailable || entry?.id === (state.doc?.documentVersion || "");
el.versionExport.disabled = unavailable || !entry?.id;
}
async function exportManagedVersion() {
const manager = state.versionManagement;
const entry = manager?.entries?.find(item => item.id === manager.id);
if (!entry?.id || !state.ready || state.exporting || manager.loading || manager.failed || document.body.hasAttribute("aria-busy")) return;
if (state.doc.signatureCount && !window.confirm("版本副本可能使原签名失效,是否继续?")) return;
const openSeq = state.openSeq;
const name = `${baseFileName()}_${entry.name || `版本${entry.index}`}`.replace(/[\\/:*?"<>|\u0000-\u001f]/g, "_") + ".ofd";
state.exporting = true;
setBusy(true, "正在另存版本");
el.versionStatus.textContent = "正在另存版本";
try {
const file = window.showSaveFilePicker ? await window.showSaveFilePicker({suggestedName: name, types: [{description: "OFD", accept: {"application/ofd": [".ofd"]}}]}) : null;
if (openSeq !== state.openSeq) return;
const result = await callWASM("ofdgoExportVersion", entry.id, file);
if (openSeq !== state.openSeq) return;
if (result.blob) downloadBytes(result.blob, result.mime, name);
el.versionStatus.textContent = `已另存(${formatBytes(result.size)})`;
} catch (error) {
if (openSeq === state.openSeq) el.versionStatus.textContent = error.name === "AbortError" ? "另存已取消" : error.message;
} finally {
state.exporting = false;
state.exportRequestID = 0;
el.cancelExportButton.hidden = true;
if (openSeq === state.openSeq) { setBusy(false); updateControls(); }
}
}
function managedDocumentIndex() {
return state.documentManagement?.index ?? state.doc?.documentIndex ?? 0;
}
function documentEntryKey(entry) {
return JSON.stringify([entry?.root, entry?.id]);
}
async function openDocumentManagement() {
if (!state.editing || !state.ready || state.exporting || document.body.hasAttribute("aria-busy")) return;
const index = state.doc.documentIndex || 0;
state.documentManagement = { index, key: documentEntryKey(state.doc.documents[index]), entries: null, loading: false, failed: false };
renderDocumentManagement();
el.documentPanel.showModal();
await refreshDocumentManagement(true);
}
async function refreshDocumentManagement(focus = false) {
const manager = state.documentManagement, openSeq = state.openSeq;
if (!manager || !el.documentPanel.open) return;
manager.loading = true;
manager.failed = false;
el.documentStatus.textContent = "正在读取文档";
updateDocumentControls();
try {
const entries = await callWASM("ofdgoPackageDocuments");
if (state.documentManagement !== manager || !el.documentPanel.open) return;
if (openSeq !== state.openSeq) return await refreshDocumentManagement(focus);
const index = entries.findIndex(entry => documentEntryKey(entry) === manager.key);
manager.index = index < 0 ? Math.min(manager.index, entries.length - 1) : index;
manager.entries = entries;
manager.key = documentEntryKey(entries[manager.index]);
el.documentStatus.textContent = "";
renderDocumentManagement();
if (focus) el.documentList.children[manager.index]?.focus();
} catch (error) {
if (state.documentManagement === manager && openSeq === state.openSeq) {
manager.failed = true;
el.documentStatus.textContent = error.message;
}
} finally {
if (state.documentManagement === manager) {
manager.loading = false;
updateDocumentControls();
}
}
}
function renderDocumentManagement() {
const manager = state.documentManagement;
if (!manager) return;
const entries = manager.entries || state.doc.documents;
el.documentList.replaceChildren(...entries.map(entry => {
const button = document.createElement("button");
button.type = "button";
button.className = "document-entry";
button.setAttribute("role", "option");
button.setAttribute("aria-selected", String(entry.index === manager.index));
button.tabIndex = entry.index === manager.index ? 0 : -1;
const name = document.createElement("span");
name.className = "document-name";
name.textContent = `${entry.index + 1} · ${entry.title || "未命名"}`;
button.title = name.textContent;
const pages = document.createElement("span");
pages.className = "document-pages";
const count = entry.pageCount ?? (entry.index === state.doc.documentIndex ? state.doc.pageCount : null);
pages.textContent = count === null ? "-" : `${count}页`;
button.append(name, pages);
button.addEventListener("click", () => selectManagedDocument(entry.index));
return button;
}));
updateDocumentControls();
}
function selectManagedDocument(index, focus = false) {
const manager = state.documentManagement;
if (!manager || manager.loading || document.body.hasAttribute("aria-busy") || index < 0 || index >= state.doc.documents.length) return;
manager.index = index;
manager.key = documentEntryKey(state.doc.documents[index]);
for (const [i, button] of [...el.documentList.children].entries()) {
button.setAttribute("aria-selected", String(i === index));
button.tabIndex = i === index ? 0 : -1;
}
updateDocumentControls();
if (focus) el.documentList.children[index]?.focus();
}
async function switchDocument(index) {
if (!state.doc || document.body.hasAttribute("aria-busy") || state.exporting || formDialogOpen()) return;
const previous = state.doc.documentIndex || 0;
if (index === previous) return;
if (!await canvasEditor.commitNudge() || !await canvasEditor.commitText() || !await canvasEditor.commitCrop()) {
el.documentSelect.value = String(previous);
return;
}
const openSeq = ++state.openSeq;
setBusy(true, "正在切换文档");
try {
const doc = await callWASM("ofdgoSelectDocument", index);
if (openSeq !== state.openSeq) return;
state.packageDirty ||= state.dirty;
state.savedRevision = doc.revision;
state.editorViews.clear();
state.objectClipboard = state.styleClipboard = null;
resetCompositeScope();
canvasEditor.clear();
canvasEditor.setTool("");
delete state.textDefaults.fontChoice;
updateTextFonts(null, true);
state.ofdBytes = null;
setEditorInfo(doc);
await openDocument({ doc, openSeq, skipAutoFonts: true, resetScroll: true, clearSelection: true, fitMode: state.fitMode, scale: state.scale, activateActions: true });
} catch (error) {
if (openSeq === state.openSeq) {
el.documentSelect.value = String(previous);
showError(error, false);
}
} finally {
if (openSeq === state.openSeq) setBusy(false);
}
}
function editorLocation() {
const pending = canvasEditor.pendingSelection;
const index = pending?.index ?? canvasEditor.selected?.index ?? state.pageIndex;
return { page: state.doc.pages[index].id, scope: state.composite?.key || "", ids: pending?.ids || canvasEditor.items().map(item => item.id) };
}
function rememberEditorView(before, revision, reindex = false) {
for (const key of state.editorViews.keys()) {
if (key > revision) state.editorViews.delete(key);
}
state.editorViews.set(state.editorInfo.revision, { before, after: editorLocation(), reindex });
if (state.editorViews.size > 100) state.editorViews.delete(state.editorViews.keys().next().value);
}
function pageCan(capability, index = state.pageIndex) {
const bit = { insert: 1, copy: 2, delete: 4, move: 8, resize: 16 }[capability];
return Boolean(state.editorInfo?.pageCapabilities?.[index] & bit);
}
async function toggleEditor() {
if (!state.doc || !state.ready || document.body.hasAttribute("aria-busy") || formDialogOpen()) return;
if (state.editorInfo) {
if (!await canvasEditor.commitText() || !await canvasEditor.commitCrop()) return;
const anchor = scaleAnchor(0);
state.editing = !state.editing;
renderDocumentFonts();
resetCompositeScope();
canvasEditor.clear();
canvasEditor.setTool("");
if (state.editing) {
releaseMediaPlayers();
state.selectObjects = true;
setPan(false);
}
updateControls();
renderOutlines(false);
if (state.renderMode !== "svg") {
try {
await refreshPageDisplay(anchor);
} catch (err) {
state.editing = !state.editing;
renderDocumentFonts();
updateControls();
showError(err, false);
}
return;
}
renderPageList();
applyFit(false);
restoreScaleAnchor(anchor);
updatePageListCurrent(true);
return;
}
const { pageIndex, fitMode, scale } = state;
const anchor = scaleAnchor(0);
const openSeq = ++state.openSeq;
setBusy(true, "正在准备编辑", null, "正在准备编辑");
try {
const doc = await callWASM("ofdgoEditDocument", pageIndex);
if (openSeq !== state.openSeq) return;
state.savedRevision = doc.revision;
state.editorViews.clear();
setEditorInfo(doc);
state.editing = true;
releaseMediaPlayers();
state.composite = null;
state.selectObjects = true;
state.ofdBytes = null;
state.conversionWarnings = [];
state.objectClipboard = null;
state.styleClipboard = null;
canvasEditor.clear();
setPan(false);
await openDocument({ doc, openSeq, skipAutoFonts: true, pageIndex, fitMode, scale,
keepPreview: true, preserveThumbnails: true, previewAnchor: anchor });
} catch (err) {
if (openSeq === state.openSeq) showError(err, false);
} finally {
if (openSeq === state.openSeq) {
setBusy(false);
updateControls();
if (state.editing) updatePageListCurrent(true);
}
}
}
function confirmTextReflow(item) {
return canEditObject(item, "reflow") && (canEditObject(item, "layoutKnown")
|| window.confirm("将重新排版原文,是否继续?"));
}
function openCreatePanel(insert = false, packageDocument = false) {
if (document.body.hasAttribute("aria-busy")) {
return;
}
el.createForm.reset();
state.createInsert = insert === true;
state.createPackage = packageDocument === true;
state.createDocumentAt = state.createPackage ? managedDocumentIndex() + 1 : null;
el.createMode.value = state.createInsert ? "images" : "blank";
el.createSubmit.textContent = state.createInsert ? "插入" : state.createPackage ? "新增" : "新建";
el.createPanel.setAttribute("aria-label", state.createInsert ? "插入图片" : state.createPackage ? "新增文档" : "新建文档");
el.createCancel.disabled = false;
updateCreateOptions();
el.createStatus.textContent = "";
el.createPanel.showModal();
}
function updateCreateOptions() {
const images = el.createMode.value === "images", busy = Boolean(state.imageImporting);
const toggle = (id, visible) => { document.getElementById(id).hidden = !visible; };
toggle("createModeRow", !state.createInsert && !state.createPackage);
el.createName.closest(".form-row").hidden = Boolean(state.createInsert);
el.createName.disabled = busy || Boolean(state.createInsert);
el.createMode.disabled = busy;
for (const [id, input] of [["createImagesRow", el.createImages], ["createSizeRow", el.createSize], ["createMarginRow", el.createMargin]]) {
toggle(id, images);
input.disabled = busy || !images;
}
el.createImages.required = images;
const custom = !images || el.createSize.value === "custom";
toggle("createDimensions", custom);
el.createWidth.disabled = el.createHeight.disabled = busy || !custom;
toggle("createPositionRow", Boolean(state.createInsert));
el.createPosition.disabled = busy || !state.createInsert;
el.createSubmit.disabled = busy;
}
function imagePageRequest() {
const files = [...el.createImages.files].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
const size = el.createSize.value;
return [files, {
Width: size === "image" ? 0 : Number(el.createWidth.value),
Height: size === "image" ? 0 : Number(el.createHeight.value),
Margin: Number(el.createMargin.value),
}];
}
async function createDocument(event) {
event.preventDefault();
if (state.createPackage) {
if (await changeDocument("ofdgoChangeDocument", null, "add", el.createName.value.trim() || "未命名",
Number(el.createWidth.value), Number(el.createHeight.value), state.createDocumentAt)) el.createPanel.close();
return;
}
if (state.createInsert) {
const at = { before: state.pageIndex, after: state.pageIndex + 1, first: 0, last: state.doc.pageCount }[el.createPosition.value];
if (await changeDocument("ofdgoImportImages", null, ...imagePageRequest(), at, "", state.renderAnnotations)) el.createPanel.close();
return;
}
if (document.body.hasAttribute("aria-busy") || !discardChanges()) {
return;
}
const title = el.createName.value.trim() || "未命名";
clearSecurityForms();
const openSeq = ++state.openSeq;
const images = el.createMode.value === "images";
state.imageImporting = images;
setBusy(true, "正在新建文档", null, "正在新建文档");
try {
await ensureWASM();
if (openSeq !== state.openSeq) {
return;
}
const doc = images
? await callWASM("ofdgoImportImages", ...imagePageRequest(), -1, title, state.renderAnnotations)
: await callWASM("ofdgoCreateDocument", title, Number(el.createWidth.value), Number(el.createHeight.value), state.renderAnnotations);
if (openSeq !== state.openSeq) {
return;
}
state.editing = true;
state.composite = null;
state.objectClipboard = null;
state.editorViews.clear();
state.styleClipboard = null;
state.fontRenderPending = false;
state.annotationCreate = null;
canvasEditor.setTool("");
canvasEditor.clear();
delete state.textDefaults.fontChoice;
updateTextFonts(null, true);
state.selectObjects = true;
setPan(false);
state.ofdBytes = null;
state.fileName = `${title.replace(/[\\/:*?"<>|]+/g, "_").replace(/\.ofd$/i, "")}.ofd`;
state.conversionWarnings = [];
state.savedRevision = null;
state.packageDirty = false;
state.encryptionDirty = false;
setEditorInfo(doc);
el.createPanel.close();
await openDocument({ doc, openSeq, skipAutoFonts: true, resetScroll: true });
} catch (err) {
if (openSeq === state.openSeq) {
el.createStatus.textContent = err.message;
}
} finally {
if (images) {
state.imageImporting = false;
state.exportRequestID = 0;
el.cancelExportButton.hidden = true;
el.createCancel.disabled = false;
}
if (openSeq === state.openSeq) {
setBusy(false);
updateControls();
}
}
}
function editAnnotationDetails(item) {
state.annotationEdit = item;
el.annotationRemark.value = item.remark || "";
el.annotationCreator.value = item.creator || "";
el.annotationFields.hidden = el.annotationSubmit.hidden = false;
el.annotationDetail.hidden = true;
el.annotationStatus.textContent = "";
el.annotationClose.textContent = "取消";
el.annotationNote.showModal();
el.annotationRemark.focus();
}
function openObjectPicker(items = canvasEditor.pageItems(state.pageIndex)) {
el.objectPickerList.replaceChildren();
const labels = { TextObject: "文字", PathObject: "图形", ImageObject: "图片", CompositeObject: "组合", CompositeGraphicUnit: "组合", Annotation: "注解" };
const kinds = { Path: "批注", Stamp: "印章", Watermark: "水印", Link: "链接", Highlight: "高亮" };
for (const item of [...items].reverse()) {
const button = document.createElement("button");
button.className = "button";
button.type = "button";
button.textContent = `${kinds[item.annotationType] || labels[item.type] || "对象"} · ${item.text || item.remark || item.id.replace("annotation:", "")}`;
button.title = button.textContent;
button.addEventListener("click", () => {
el.objectPicker.close();
canvasEditor.select(item);
canvasEditor.focus();
});
el.objectPickerList.append(button);
}
if (!items.length) { setStatus("页面暂无对象"); return; }
el.objectPicker.showModal();
}
function openAnnotationCreate(kind, index, box = null, item = null, info = null) {
const page = state.doc.pages[index];
const width = Math.min(60, page.width), height = 12;
state.annotationCreate = { kind, index, item, info, openSeq: state.openSeq, height: box?.height || height };
annotationFields.CreateForm.reset();
annotationFields.Text.value = kind === "watermark" ? "水印" : "";
annotationFields.Pages.value = String(index + 1);
annotationFields.Target.max = state.doc.pageCount;
annotationFields.Target.value = String(index + 1);
annotationFields.LinkKind.querySelector('[value="keep"]').hidden = !item;
if (kind === "link" && info) {
annotationFields.LinkKind.value = info.linkKind;
annotationFields.Address.value = info.linkURI || "";
if (info.linkPage) annotationFields.Target.value = String(info.linkPage);
}
annotationFields.X.value = box?.x ?? (page.width - width) / 2;
annotationFields.Y.value = box?.y ?? (page.height - height) / 2;
annotationFields.Width.value = box?.width || width;
annotationFields.Angle.value = kind === "watermark" ? -30 : 0;
annotationFields.Opacity.value = kind === "watermark" ? 80 : 0;
annotationFields.Old.replaceChildren(...(info?.texts || []).map(text => new Option(text, text)));
if (kind === "replace") annotationFields.Text.value = info?.texts[0] || "";
el.annotationCreateStatus.textContent = "";
el.annotationCreate.setAttribute("aria-label", {note:"添加批注",watermark:"添加水印",stamp:"添加印章",link: item ? "修改链接" : "添加链接",replace:"替换文字"}[kind]);
updateAnnotationFields();
el.annotationCreate.showModal();
const focus = [annotationFields.Text, annotationFields.Image, annotationFields.Address].find(input => !input.disabled);
focus?.focus();
}
function pickWatermarkArea() {
state.annotationCreate.picking = true;
annotationFields.Placement.value = state.annotationCreate.area ? "custom" : "page";
el.annotationCreate.close();
canvasEditor.select(null);
toggleDrawingTool("annotation:watermark");
}
function placeAnnotation(index, kind, box) {
const entry = state.annotationCreate;
if (kind !== "watermark" || !entry?.picking) {
openAnnotationCreate(kind, index, box);
return;
}
entry.picking = false;
const page = state.doc.pages[index], tile = annotationFields.Tile.value === "tile";
const x = Math.max(0, Math.min(1, box.x / page.width));
const y = Math.max(0, Math.min(1, box.y / page.height));
const right = Math.max(x, Math.min(1, (box.x + box.width) / page.width));
const bottom = Math.max(y, Math.min(1, (box.y + box.height) / page.height));
if (!tile || right > x && bottom > y) {
entry.index = index;
entry.area = tile ? { x, y, w: right - x, h: bottom - y } : { x: (x + right) / 2, y: (y + bottom) / 2, w: 0, h: 0 };
annotationFields.Placement.value = "custom";
if (annotationFields.Scope.value !== "custom") annotationFields.Pages.value = String(index + 1);
el.annotationCreateStatus.textContent = "";
} else {
el.annotationCreateStatus.textContent = "范围过小";
}
el.annotationCreate.showModal();
}
function updateAnnotationFields() {
const { kind, item } = state.annotationCreate;
for (const key of ["X", "Y", "Width", "Height", "Pages", "Author"]) annotationFields[key].closest(".form-row").hidden = false;
for (const row of el.annotationCreate.querySelectorAll("[data-annotation]")) row.hidden = !row.dataset.annotation.split(" ").includes(kind);
const image = kind === "stamp" || kind === "watermark" && annotationFields.Content.value === "image";
annotationFields.ImageRow.hidden = !image;
annotationFields.Image.required = image;
annotationFields.TextRow.hidden = !["note", "replace", "watermark"].includes(kind) || image;
annotationFields.AddressRow.hidden = kind !== "link" || annotationFields.LinkKind.value !== "uri";
annotationFields.TargetRow.hidden = kind !== "link" || annotationFields.LinkKind.value !== "page";
annotationFields.Height.value = image ? "" : state.annotationCreate.height;
annotationFields.Height.placeholder = image ? "等比" : "";
if (kind === "watermark") {
for (const key of ["X", "Y"]) annotationFields[key].closest(".form-row").hidden = true;
annotationFields.Pages.closest(".form-row").hidden = annotationFields.Scope.value !== "custom";
const tile = annotationFields.Tile.value === "tile";
annotationFields.PlacementLabel.textContent = tile ? "范围" : "位置";
annotationFields.Placement.options[0].textContent = tile ? "整页" : "居中";
annotationFields.Placement.options[1].textContent = tile ? "选区" : "指定";
annotationFields.Pick.textContent = tile ? "框选" : "定位";
}
if (kind === "link" && item) {
for (const key of ["X", "Y", "Width", "Height", "Pages", "Author"]) annotationFields[key].closest(".form-row").hidden = true;
}
for (const input of annotationFields.CreateForm.querySelectorAll("input,select,textarea")) {
input.disabled = Boolean(input.closest("[hidden]")) || image && input === annotationFields.Height;
}
}
async function editCanvasObject(item) {
if (!state.editing || document.body.hasAttribute("aria-busy") || canvasEditor.input || item.items) return;
if (canEditObject(item, "enter")) return enterCompositeScope(item.index, item.id);
if (item.type === "Annotation") {
editAnnotationDetails(item);
return;
}
if (item.type === "TextObject") {
if (!canEditObject(item, "textContent")) {
if (canEditObject(item, "rewriteText")) {
state.sourceText = { item, openSeq: state.openSeq };
el.sourceTextValue.value = item.text;
el.sourceTextStatus.textContent = "";
el.sourceTextPreview.replaceChildren();
el.sourceTextPanel.showModal();
el.sourceTextValue.focus();
await previewPositionedText();
}
return;
}
} else if (!canEditObject(item, item.type === "ImageObject" ? "replaceImage" : "update")) return;
if (item.type === "PathObject") {
el.shapeWidth.focus();
return;
}
if (item.type === "ImageObject") {
return openInsertPanel(item);
}
const openSeq = state.openSeq;
setBusy(true);
try {
const data = await callWASM("ofdgoEditorFont", item.font);
const face = new FontFace(`ofdgo-edit-${item.font}`, data.bytes);
await face.load();
const source = canEditObject(item, "layoutKnown") ? null : await callWASM("ofdgoPreviewText", item.index, item.id, item.text);
if (openSeq === state.openSeq && item.node.isConnected) {
canvasEditor.editText(item, face, source);
}
} catch (err) {
if (openSeq === state.openSeq) {
showError(err, false);
}
} finally {
if (openSeq === state.openSeq) {
setBusy(false);
}
}
}
async function previewPositionedText() {
const request = state.sourceText, value = el.sourceTextValue.value;
if (!request || !el.sourceTextPanel.open) return;
try {
const svg = await callWASM("ofdgoPreviewPositionedText", request.item.index, request.item.id, value);
if (state.sourceText !== request || request.openSeq !== state.openSeq || el.sourceTextValue.value !== value) return;
el.sourceTextPreview.replaceChildren(parseSVG(svg));
el.sourceTextStatus.textContent = "";
} catch (error) {
if (state.sourceText === request && request.openSeq === state.openSeq && el.sourceTextValue.value === value) {
el.sourceTextPreview.replaceChildren();
el.sourceTextStatus.textContent = error.message;
}
}
}
function mountEditorObjects(index, page, surface) {
const scope = state.composite?.index === index ? state.composite : null;
canvasEditor.mount(index, { ...page, objects: scope ? scope.objects : page.objects.filter(item => state.renderAnnotations || item.type !== "Annotation") }, surface);
}
function changeAnnotations(item, action, ...args) {
return changeDocument("ofdgoChangeAnnotations", { ...item, id: item.items ? item.id : [item.id] }, action, ...args);
}
function resetCompositeScope() {
const scope = state.composite;
if (!scope) return;
state.composite = null;
canvasEditor.clear();
const page = state.pageCache.get(scope.index), surface = pageShell(scope.index)?.querySelector(".page-surface");
if (page && surface) mountEditorObjects(scope.index, page, surface);
}
async function enterCompositeScope(index, key) {
const openSeq = state.openSeq;
setBusy(true);
try {
const objects = await callWASM("ofdgoCompositeObjects", index, key);
if (openSeq !== state.openSeq) return;
canvasEditor.clear();
canvasEditor.setTool("");
state.composite = { index, key, objects };
const page = state.pageCache.get(index), surface = pageShell(index)?.querySelector(".page-surface");
if (page && surface) mountEditorObjects(index, page, surface);
updateControls();
setStatus(pageStatus(index, state.doc.pageCount));
canvasEditor.focus();
} catch (err) {
if (openSeq === state.openSeq) showError(err, false);
} finally {
if (openSeq === state.openSeq) setBusy(false);
}
}
async function exitCompositeScope() {
const scope = state.composite;
if (!scope) return;
const split = scope.key.lastIndexOf("/");
if (split >= 0) return enterCompositeScope(scope.index, scope.key.slice(0, split));
resetCompositeScope();
updateControls();
canvasEditor.focus();
}
async function startImageCrop(item) {
if (item.scoped) {
canvasEditor.startCrop(item, { svg: item.surface.querySelector(".ofd-svg").cloneNode(true), urls: [] });
return;
}
const openSeq = state.openSeq;
const urls = [];
let mounted = false;
setBusy(true);
try {
const page = await callWASM("ofdgoPreviewImage", item.index, item.id);
if (openSeq !== state.openSeq || !item.surface.isConnected) return;
const images = new Map(state.svgImages);
for (const image of page.images) {
if (images.has(image.name)) continue;
const url = URL.createObjectURL(new Blob([image.bytes], { type: image.mime }));
urls.push(url);
images.set(image.name, { url });
}
await Promise.all(urls.map(url => {
const image = new Image();
image.src = url;
return image.decode();
}));
if (openSeq !== state.openSeq || canvasEditor.selected !== item) return;
const svg = parseSVG(page.svg, `crop${openSeq}-${item.id}`, images);
svg.classList.add("ofd-svg");
canvasEditor.startCrop(item, { svg, urls });
mounted = true;
} catch (err) {
if (openSeq === state.openSeq) showError(err, false);
} finally {
if (!mounted) urls.forEach(url => URL.revokeObjectURL(url));
if (openSeq === state.openSeq) setBusy(false);
}
}
function openInsertPanel(item = null) {
if (!state.editing || document.body.hasAttribute("aria-busy")) {
return;
}
el.insertForm.reset();
state.insertObject = item;
el.insertFitRow.hidden = !item || Boolean(item.scoped);
el.insertPanel.setAttribute("aria-label", item ? "替换图片" : "添加图片");
el.insertSubmit.textContent = item ? "替换" : "添加";
el.insertStatus.textContent = "";
el.insertPanel.showModal();
el.insertImage.focus();
}
function currentTextStyle() {
const text = selectedText(canvasEditor.selected) || state.textDefaults;
return canvasEditor.input?.style ? { ...text, ...canvasEditor.input.style } : text;
}
function textIndents(item) {
return [item.leftIndent || 0, item.rightIndent || 0, item.firstLineIndent || 0];
}
async function toggleTextTool() {
if (!state.editing || !pageCan("insert") || document.body.hasAttribute("aria-busy")) return;
if (canvasEditor.tool === "text") {
canvasEditor.setTool("");
return;
}
const openSeq = state.openSeq;
if (!state.textFonts.length) {
await requestLocalFontsBeforeOpen();
updateTextFonts(canvasEditor.selected, true);
}
if (openSeq !== state.openSeq) return;
const style = !canvasEditor.selected?.items && canEditObject(canvasEditor.selected, "layoutKnown") ? currentTextStyle() : state.textDefaults;
const font = state.textFonts[Number(fontPicker.value)];
state.textDefaults = { type: "TextObject", size: style.size, color: style.color, wrap: style.wrap,
wrapOverride: style === state.textDefaults ? style.wrapOverride : style.wrap,
align: style.align || "left", paragraphHeight: style.paragraphHeight || 0, letterSpacing: style.letterSpacing || 0,
leftIndent: style.leftIndent || 0, rightIndent: style.rightIndent || 0, firstLineIndent: style.firstLineIndent || 0,
fontChoice: font && !font.disabled ? font : state.textDefaults.fontChoice };
state.selectObjects = true;
setPan(false);
canvasEditor.setTool("text");
canvasEditor.focus();
}
async function beginCanvasText(index, box, surface, page) {
const font = state.textDefaults.fontChoice || state.textFonts[Number(fontPicker.value)];
if (!font || font.disabled) {
setStatus("尚未添加字体");
el.textFontAdd.focus();
return;
}
const openSeq = state.openSeq;
setBusy(true);
try {
const data = await readTextFont(font);
const face = new FontFace("ofdgo-draft", data);
await face.load();
if (openSeq !== state.openSeq || !surface.isConnected) return;
const node = document.createElement("div");
const item = { ...state.textDefaults, ...box, wrap: state.textDefaults.wrapOverride ?? box.wrap ?? state.textDefaults.wrap,
index, page, surface, node, text: "", draft: true, fontData: data };
canvasEditor.editText(item, face);
} catch (err) {
if (openSeq === state.openSeq) showError(err, false);
} finally {
if (openSeq === state.openSeq) setBusy(false);
}
}
function editorFonts(item) {
const fonts = fontManager.editorFonts();
if (item) {
fonts.unshift({ id: `embedded:${item.font}`, name: item.fontName || item.font, embedded: true, disabled: !canEditObject(item, "textContent") });
}
return fonts;
}
async function loadEditorFonts() {
const tasks = [];
if (!state.fontCatalogLoading && fontManager.canReadLocal() && !fontManager.catalogLoaded && fontManager.permission !== "denied") {
state.fontCatalogLoading = true;
tasks.push((async () => {
try {
await fontManager.queryLocal();
refreshEditorFonts();
} catch (err) {
setStatus(err?.name === "NotAllowedError" ? "系统字体未授权" : String(err.message || err));
} finally {
state.fontCatalogLoading = false;
}
})());
}
if (!state.fontFacesLoading && fontManager.userFonts.some(font => font.enabled && !font.faces)) {
state.fontFacesLoading = true;
tasks.push((async () => {
try {
await fontManager.loadFaces(data => callWASM("ofdgoFontFaces", data));
refreshEditorFonts();
} finally {
state.fontFacesLoading = false;
}
})());
}
await Promise.all(tasks);
}
function refreshEditorFonts() {
const open = fontPicker.open;
const selected = state.textFonts[Number(fontPicker.value)];
const query = el.textFont.value === (selected?.fullName || selected?.name || "") ? "" : el.textFont.value;
updateTextFonts(canvasEditor.selected, true);
if (open && !el.textFont.disabled) { if (query) el.textFont.value = query; fontPicker.show(query, false); }
}
async function readTextFont(font) {
if (font.embedded) return (await callWASM("ofdgoEditorFont", font.id.slice(9))).bytes;
return (await callWASM("ofdgoFontFace", await fontManager.read(font.file || font), font.index || 0)).bytes;
}
function updateTextFonts(item, refresh = false) {
item = selectedText(item);
const id = item?.type === "TextObject" && !item.draft ? item.font : null;
const key = item?.items ? `selection:${item.items.map(member => member.id).join(",")}:${id || ""}` : id;
if (refresh || state.textFontID !== key) {
state.textFontID = key;
state.textFonts = editorFonts(id ? item : null);
const selected = canvasEditor.input?.fontChoice || (id ? state.textFonts[0] : state.textDefaults.fontChoice);
if (selected && (canvasEditor.input || selected.embedded)
&& !state.textFonts.some(font => (font.id || font.postscriptName) === (selected.id || selected.postscriptName))) {
state.textFonts.unshift(selected);
}
fontPicker.setFonts(state.textFonts, item?.items && !id ? null : selected || state.textFonts[0]);
el.textFont.placeholder = item?.items && !id ? "混合" : "";
if (!id && !item?.items) state.textDefaults.fontChoice = state.textFonts[Number(fontPicker.value)];
}
}
async function changeTextFont() {
const item = selectedText(canvasEditor.selected);
const font = state.textFonts[Number(fontPicker.value)];
if (!state.ready || document.body.hasAttribute("aria-busy") || !font || font.disabled) return;
const editing = canvasEditor.input;
if (editing) {
const openSeq = state.openSeq;
setBusy(true);
try {
const data = await readTextFont(font);
await callWASM("ofdgoCheckTextFont", data, editTextValue(editing.input));
const face = new FontFace("ofdgo-edit-font", data);
await face.load();
if (openSeq !== state.openSeq || canvasEditor.input !== editing) return;
editing.fontChoice = font;
if (item.draft) state.textDefaults.fontChoice = font;
canvasEditor.setTextFont(face, data);
} catch (err) {
if (openSeq === state.openSeq) {
updateTextFonts(canvasEditor.selected, true);
showError(err, false);
}
} finally {
if (openSeq === state.openSeq) setBusy(false);
}
} else if (item?.type === "TextObject") {
if (canEditObject(item, "replaceFont") && (!font.embedded || item.font !== font.id.slice(9))) {
await changeDocument("ofdgoStyleText", { ...item, id: (item.items || [item]).map(member => member.id) }, readTextFont(font), 0, null);
updateTextFonts(canvasEditor.selected, true);
}
} else {
state.textDefaults.fontChoice = font;
}
}
async function insertObject(event) {
event.preventDefault();
if (!state.editing || document.body.hasAttribute("aria-busy") || el.insertSubmit.disabled) {
return;
}
let openSeq = state.openSeq;
const item = state.insertObject;
const before = editorLocation(), revision = state.editorInfo.revision;
const index = item?.index ?? state.pageIndex;
const page = state.doc.pages[index];
const x = Math.min(20, page.width / 10);
const y = Math.min(20, page.height / 10);
el.insertStatus.textContent = "";
setBusy(true);
try {
const data = new Uint8Array(await el.insertImage.files[0].arrayBuffer());
if (openSeq !== state.openSeq) {
return;
}
const doc = item?.scoped
? await callWASM("ofdgoChangeCompositeObjects", item.index, state.composite.key, [item.id], "image", data)
: item ? await callWASM("ofdgoReplaceImage", item.index, item.id, data, el.insertFit.value)
: await callWASM("ofdgoInsertImage", index, data, x, y, page.width - x * 2, state.composite?.key || "");
if (openSeq !== state.openSeq) {
return;
}
el.insertPanel.close();
if (!item && resourceElements.Panel.open) resourceElements.Panel.close();
if (item && doc.revision === state.editorInfo?.revision) {
canvasEditor.focus();
return;
}
setEditorInfo(doc);
canvasEditor.pendingSelection = item ? null : { index, ids: doc.selectedIDs };
state.selectObjects = true;
setPan(false);
openSeq = ++state.openSeq;
await refreshEditorPage(doc, index, openSeq);
if (openSeq === state.openSeq) {
rememberEditorView(before, revision, !item && !!state.composite);
canvasEditor.focus();
}
} catch (err) {
if (openSeq === state.openSeq) {
if (el.insertPanel.open) {
el.insertStatus.textContent = err.message;
} else {
showError(err, false);
}
}
} finally {
if (openSeq === state.openSeq) {
setBusy(false);
}
}
}
function displayPoints(length) {
return Number((length * 72 / 25.4).toPrecision(6));
}
function inputMillimeters(input, original) {
const points = Number(input.value);
return points === displayPoints(original) ? original : points * 25.4 / 72;
}
async function changeParagraph(wrap) {
const item = currentTextStyle();
const explicitWrap = wrap !== undefined;
wrap ??= item.wrap;
if (document.body.hasAttribute("aria-busy") || item.draft) return;
if (!el.textLineHeight.checkValidity()) {
updateObjectControls(item, true);
return;
}
const height = inputMillimeters(el.textLineHeight, item.paragraphHeight || 0);
if (!el.textSpacing.checkValidity()) {
updateObjectControls(canvasEditor.selected, true);
return;
}
const spacing = inputMillimeters(el.textSpacing, item.letterSpacing || 0);
if (item === state.textDefaults) {
Object.assign(item, { wrap: Boolean(wrap), align: el.textAlign.value, paragraphHeight: height, letterSpacing: spacing });
if (explicitWrap) item.wrapOverride = Boolean(wrap);
updateObjectControls(canvasEditor.selected, true);
return;
}
if (!confirmTextReflow(item)) {
updateObjectControls(item, true);
return;
}
await changeDocument("ofdgoLayoutText", item, null, null, Boolean(wrap), el.textAlign.value, height, spacing, ...textIndents(item));
updateObjectControls(canvasEditor.selected, true);
}
async function changeTextStyle(color) {
const item = currentTextStyle();
if (!state.ready || state.exporting || document.body.hasAttribute("aria-busy")) {
return;
}
if (!color && !el.textSize.checkValidity()) {
showError(new Error("字号无效"), false);
updateObjectControls(item, true);
return;
}
const size = color ? item.size : inputMillimeters(el.textSize, item.size);
const fill = color && el.textColor.value !== item.color ? el.textColor.value : null;
if (canvasEditor.input) {
canvasEditor.setTextStyle(color ? { color: el.textColor.value } : { size });
updateObjectControls(canvasEditor.selected, true);
return;
}
if (state.composite && color) {
if (color && fill !== null && canEditObject(item, "paint")) {
await changeDocument("ofdgoCompositeTextColor", item, fill);
}
updateObjectControls(canvasEditor.selected, true);
return;
}
if (item.items) {
if (color ? !canEditObject(item, "paint") : !canEditObject(item, "layoutKnown") || !canEditObject(item, "reflow")) return;
if (!color && !el.textSize.value) return;
if (size !== item.size || fill !== null) {
await changeDocument("ofdgoStyleText", { ...item, id: item.items.map(member => member.id) }, null, color ? 0 : size, fill);
}
updateObjectControls(canvasEditor.selected, true);
return;
}
if (item === state.textDefaults) {
item.size = size;
if (fill !== null) item.color = fill;
updateObjectControls(canvasEditor.selected, true);
return;
}
if (size !== item.size || fill !== null) {
if (color ? !canEditObject(item, "paint") : !confirmTextReflow(item)) {
updateObjectControls(item, true);
return;
}
await changeDocument("ofdgoUpdateText", item, color ? null : item.text, null, size, fill);
}
updateObjectControls(canvasEditor.selected, true);
}
function shapeStyle() {
return { fill: el.shapeFill.checked, fillColor: el.shapeFillColor.value,
stroke: el.shapeStroke.checked, strokeColor: el.shapeStrokeColor.value,
lineWidth: Number(el.shapeWidth.value) * 25.4 / 72 };
}
function selectedPath(item) {
if (!item?.items) return item?.type === "PathObject" ? item : null;
if (!item.items.every(member => member.type === "PathObject")) return null;
const path = { ...item, type: "PathObject", shape: item.items.every(member => lineShape(member.shape)) ? "line" : "" };
for (const key of ["fill", "stroke", "fillColor", "strokeColor", "lineWidth"]) {
const value = item.items[0][key];
path[key] = item.items.every(member => member[key] === value) ? value : undefined;
}
return path;
}
async function changeShapeStyle({ target }) {
const item = selectedPath(canvasEditor.selected);
if (!state.editing || document.body.hasAttribute("aria-busy")) {
return;
}
if (canvasEditor.selected && (!item || !canEditObject(item, "paint"))) return;
if (target === el.shapeWidth && (!el.shapeWidth.checkValidity() || Number(el.shapeWidth.value) <= 0)) {
el.shapeWidth.value = item ? item.lineWidth === undefined ? "" : String(displayPoints(item.lineWidth)) : "1";
return;
}
if (item) {
const values = [el.shapeFill, el.shapeFillColor, el.shapeStroke, el.shapeStrokeColor, el.shapeWidth].map(input => {
if (input !== target) return null;
if (input === el.shapeFill || input === el.shapeStroke) return input.checked;
return input === el.shapeWidth ? inputMillimeters(input, item.lineWidth) : input.value;
});
await changeDocument("ofdgoUpdatePathStyle", { ...item, id: (item.items || [item]).map(member => member.id) }, ...values);
updateObjectControls(canvasEditor.selected, true);
} else {
el.shapeWidth.value = String(Number(el.shapeWidth.value));
}
updateDrawingControls();
}
function toggleDrawingTool(tool) {
if (!state.editing || document.body.hasAttribute("aria-busy")) return;
const next = canvasEditor.tool === tool ? "" : tool;
state.selectObjects = true;
setPan(false);
canvasEditor.setTool(next);
canvasEditor.focus();
}
function updateDrawingControls() {
const tool = canvasEditor.tool;
const path = selectedPath(canvasEditor.selected);
el.insertTextButton.setAttribute("aria-pressed", String(tool === "text"));
const line = lineShape(tool) || tool.startsWith("pen-") || !tool && lineShape(path?.shape);
if (line) {
el.shapeFill.checked = false;
el.shapeStroke.checked = true;
} else if (!path && !el.shapeFill.checked && !el.shapeStroke.checked) {
el.shapeStroke.checked = true;
}
const disabled = !state.editing || !state.ready || state.exporting;
el.arrowTool.disabled = disabled || !pageCan("insert");
el.penTool.disabled = el.arrowTool.disabled;
el.penTool.classList.toggle("active", tool.startsWith("pen-"));
el.annotationTool.disabled = disabled;
for (const [button, name] of [[el.drawLineButton, "line"], [el.drawRectangleButton, "rectangle"], [el.drawEllipseButton, "ellipse"]]) {
button.disabled = disabled || !pageCan("insert");
button.setAttribute("aria-pressed", String(tool === name));
}
el.eraseButton.disabled = el.eraseMode.disabled = disabled;
el.eraseButton.setAttribute("aria-pressed", String(tool.startsWith("erase-")));
el.selectObjectButton.setAttribute("aria-pressed", String(canvasEditor.enabled && !tool));
const styleDisabled = disabled || (canvasEditor.selected ? !path || !canEditObject(path, "paint") : !pageCan("insert"));
el.shapeFill.disabled = el.shapeStroke.disabled = styleDisabled || line;
el.shapeFillColor.disabled = styleDisabled || line || !el.shapeFill.checked && !el.shapeFill.indeterminate;
el.shapeStrokeColor.disabled = el.shapeWidth.disabled = styleDisabled || !line && !el.shapeStroke.checked && !el.shapeStroke.indeterminate;
}
async function changeDocument(name, item, ...args) {
if (!state.editing || document.body.hasAttribute("aria-busy")) {
return;
}
let openSeq = state.openSeq;
const active = document.activeElement;
const toolbarFocus = el.editorTools.contains(active) ? active : null;
const previous = currentPageInfo();
const before = editorLocation(), revision = state.editorInfo.revision;
const restoring = name === "ofdgoUndo" || name === "ofdgoRedo";
const { scrollLeft, scrollTop } = el.viewerPanel;
const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages";
const changingFont = name === "ofdgoChangeFontBinding";
let refreshManagement = false;
state.imageImporting = name === "ofdgoImportImages";
if (importing) state.importing = true;
setBusy(true, importing ? "正在导入页面" : "", null);
const progressTimer = changingFont ? window.setTimeout(() => {
if (openSeq === state.openSeq && document.body.hasAttribute("aria-busy")) {
el.progressPanel.hidden = false;
setProgress("正在处理字体", null);
}
}, 250) : null;
try {
args = await Promise.all(args);
if (openSeq !== state.openSeq) {
return;
}
const scope = state.composite;
const operation = { ofdgoTransformObject: "transform", ofdgoTransformObjects: "transform", ofdgoRotateObjects: "rotate", ofdgoFlipObjects: "flip", ofdgoResizeObjects: "resize",
ofdgoAlignObject: "align", ofdgoAlignObjects: "align", ofdgoDistributeObjects: "distribute", ofdgoStyleObjects: "style", ofdgoUpdatePathStyle: "paint", ofdgoCompositeTextColor: "textColor",
ofdgoUpdateText: "text", ofdgoStyleText: "textStyle", ofdgoCropImage: "crop", ofdgoLayoutText: "layout", ofdgoFitImage: "fit", ofdgoResetCompositeCrop: "resetCrop",
ofdgoReshapeObject: "reshape", ofdgoReshapeLine: "line", ofdgoEraseObjects: "erase", ofdgoEraseObjectsPath: "erasePath", ofdgoPasteStyle: "pasteStyle",
ofdgoDeleteObject: "delete", ofdgoDeleteObjects: "delete", ofdgoCopyObjects: "copy", ofdgoOrderObjects: "order", ofdgoGroupObjects: "group" }[name];
const members = item?.items || (item ? [item] : []);
const scoped = scope && members.length && members.every(member => member.scoped);
const ids = item ? Array.isArray(item.id) ? item.id : members.map(member => member.id) : [];
const annotated = !scoped && ids.some(id => id.startsWith("annotation:"));
const inserting = ["ofdgoInsertShape", "ofdgoInsertText", "ofdgoInsertImage", "ofdgoInsertInk"].includes(name);
const editingAnnotation = name === "ofdgoWriteAnnotation" && Boolean(state.annotationCreate?.item);
if (scope && inserting) args.push(scope.key);
if (scoped && !operation) throw new Error("内部对象暂不支持此操作");
const doc = scoped
? await callWASM("ofdgoChangeCompositeObjects", item.index, scope.key, Array.isArray(item.id) ? item.id : members.map(member => member.id), operation, ...args)
: annotated && operation ? await callWASM("ofdgoChangeSelection", item.index, ids, operation, ...args)
: await callWASM(name, ...(item ? [item.index, item.id] : []), ...args);
if (openSeq !== state.openSeq) {
return;
}
if (doc.revision === state.editorInfo?.revision) {
return true;
}
const restoredView = name === "ofdgoUndo" ? state.editorViews.get(revision) : name === "ofdgoRedo" ? state.editorViews.get(doc.revision) : null;
if (restoredView?.reading && state.savedRevision === revision) state.savedRevision = doc.revision;
refreshManagement = el.documentPanel.open && (name === "ofdgoChangeDocument" || name === "ofdgoUpdateInfo" || restoring);
const previousDocument = state.doc.documents?.[state.doc.documentIndex || 0];
const nextDocument = doc.documents?.[doc.documentIndex || 0];
const sameDocument = previousDocument?.id && previousDocument.id === nextDocument?.id && state.doc.documentIndex === doc.documentIndex;
const documentChanged = (state.doc.documentVersion || "") !== (doc.documentVersion || "")
|| previousDocument?.id !== nextDocument?.id || previousDocument?.root !== nextDocument?.root
&& !(sameDocument && (name === "ofdgoChangeFontBinding" || restoring));
const packageChanged = documentChanged || name === "ofdgoChangeDocument" || restoring
&& JSON.stringify(doc.documents?.map(entry => [entry.root, entry.id])) !== JSON.stringify(state.doc.documents?.map(entry => [entry.root, entry.id]));
if (packageChanged) {
if (state.documentManagement && name === "ofdgoChangeDocument" && args[0] === "add") {
state.documentManagement.key = documentEntryKey(nextDocument);
}
resetCompositeScope();
state.objectClipboard = state.styleClipboard = null;
state.pageSelection.clear();
state.pageSelectionAnchor = null;
state.pageMultiSelect = false;
state.outlineSelection = null;
state.outlineExpanded.clear();
canvasEditor.clear();
canvasEditor.setTool("");
if (documentChanged) {
delete state.textDefaults.fontChoice;
updateTextFonts(null, true);
}
state.ofdBytes = null;
setEditorInfo(doc);
openSeq = ++state.openSeq;
await openDocument({ doc, openSeq, skipAutoFonts: true, resetScroll: documentChanged, clearSelection: true,
pageIndex: documentChanged ? 0 : state.pageIndex, fitMode: state.fitMode, scale: state.scale });
if (openSeq === state.openSeq) {
if (!restoring) rememberEditorView(before, revision);
return true;
}
return;
}
const view = restoredView;
const reindex = !!scope && (scoped && ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoCopyObjects", "ofdgoOrderObjects", "ofdgoEraseObjects", "ofdgoEraseObjectsPath", "ofdgoGroupObjects"].includes(name)
|| inserting || name === "ofdgoPasteObjects" && !!args[4]);
const reindexed = restoring ? view?.reindex && view.before : reindex && before;
const clipboard = state.objectClipboard;
if (reindexed && clipboard?.scope?.startsWith(reindexed.scope + "/") && clipboard.page === reindexed.page) state.objectClipboard = null;
const location = name === "ofdgoUndo" ? view?.before : view?.after;
const restoredIndex = location ? pageIndexByID(doc.pages, location.page) : -1;
if (restoredIndex >= 0) {
state.composite = location.scope ? { index: restoredIndex, key: location.scope, objects: [] } : null;
} else if (!item && !inserting && !editingAnnotation && !(name === "ofdgoPasteObjects" && args[4])) {
resetCompositeScope();
}
if (restoring && JSON.stringify(doc.outlines) !== JSON.stringify(state.doc.outlines)) {
state.outlineSelection = null;
state.outlineExpanded.clear();
}
setEditorInfo(doc);
if (name === "ofdgoUpdateInfo") {
Object.assign(state.doc, { title: doc.title, author: doc.author, subject: doc.subject, customData: doc.customData,
documents: doc.documents, documentIndex: doc.documentIndex });
renderMeta();
updateControls();
rememberEditorView(before, revision);
return true;
}
if (name === "ofdgoChangeOutline" || name === "ofdgoMoveOutline") {
remapOutlineExpansion(name, args, doc.outlinePath);
state.doc.outlines = doc.outlines;
state.outlineSelection = doc.outlinePath;
renderOutlines(false);
showNavigation(el.outlinesTab);
updateControls();
rememberEditorView(before, revision);
return true;
}
const clearSelection = !item && !editingAnnotation || name === "ofdgoChangeAnnotations" && args[0] === "delete"
|| ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoEraseObjects", "ofdgoEraseObjectsPath"].includes(name);
if (name === "ofdgoCopyObjects" || name === "ofdgoPasteObjects" || name === "ofdgoGroupObjects" || inserting || name === "ofdgoWriteAnnotation" && !editingAnnotation) {
state.selectObjects = true;
if (name !== "ofdgoInsertInk") canvasEditor.setTool("");
setPan(false);
canvasEditor.pendingSelection = { index: item?.index ?? args[0], ids: name === "ofdgoInsertInk" ? [] : doc.selectedIDs };
}
if (scoped && name === "ofdgoOrderObjects") canvasEditor.pendingSelection = { index: item.index, ids: doc.selectedIDs };
openSeq = ++state.openSeq;
if (changingFont) {
await refreshFontBinding(doc, openSeq);
} else if (item || name === "ofdgoPasteObjects" || inserting || editingAnnotation) {
await refreshEditorPage(doc, item ? item.index : args[0], openSeq, clearSelection);
} else {
const samePage = pageIndexByID(doc.pages, previous.id);
const pageIndex = restoredIndex >= 0 ? restoredIndex : doc.pageIndex ?? (samePage < 0 ? Math.min(state.pageIndex, doc.pageCount - 1) : samePage);
const page = doc.pages[pageIndex];
const keepScroll = doc.pageIndex === undefined && page.id === previous.id && pageIndex === state.pageIndex
&& page.width === previous.width && page.height === previous.height;
await openDocument({ doc, openSeq, skipAutoFonts: true, pageIndex, fitMode: state.fitMode, scale: state.scale,
keepPreview: true, clearSelection, selection: restoredIndex >= 0 ? { index: restoredIndex, ids: location.ids } : null,
previewScroll: keepScroll ? { scrollLeft, scrollTop } : null });
}
if (openSeq === state.openSeq) {
if (!restoring) rememberEditorView(before, revision, reindex);
if (el.annotationsTab.getAttribute("aria-selected") === "true") await loadAnnotationSummary();
if (!toolbarFocus && !changingFont) canvasEditor.focus();
return true;
}
} catch (err) {
refreshManagement = false;
if (name === "ofdgoChangeFontBinding") err.message = fontBindingError(err.message);
if (openSeq === state.openSeq) {
if (settingsElements.Panel.open) {
settingsElements.Status.textContent = err.message;
} else if (fontBindingElements.Panel.open) {
fontBindingElements.Status.textContent = fontBindingError(err.message);
} else if (el.annotationCreate.open) {
el.annotationCreateStatus.textContent = err.message;
} else if (el.annotationNote.open) {
el.annotationStatus.textContent = err.message;
} else if (el.batchPagesPanel.open) {
el.batchPagesStatus.textContent = err.message;
} else if (el.objectStylePanel.open) {
el.objectStyleStatus.textContent = err.message;
} else if (el.sourceTextPanel.open) {
el.sourceTextStatus.textContent = err.message;
} else if (el.objectBoundsPanel.open) {
el.objectBoundsStatus.textContent = err.message;
} else if (el.outlinePanel.open) {
el.outlineStatus.textContent = err.message;
} else if (el.structureDialog.open) {
el.structureStatus.textContent = err.message;
} else if (el.infoPanel.open) {
el.infoStatus.textContent = err.message;
} else if (el.attachmentDialog.open) {
el.attachmentStatus.textContent = err.message;
} else if (el.pagePanel.open) {
el.pageStatus.textContent = err.message;
} else if (el.paragraphPanel.open) {
el.paragraphStatus.textContent = err.message;
} else if (el.createPanel.open) {
el.createStatus.textContent = err.message;
} else if (el.importPanel.open) {
el.importStatus.textContent = err.message;
} else if (el.versionPanel.open) {
el.versionStatus.textContent = err.message;
} else if (el.documentPanel.open) {
el.documentStatus.textContent = err.message;
} else if (resourceElements.Panel.open) {
resourceElements.Status.textContent = resourceError(err.message);
} else {
showError(err, false);
}
}
} finally {
if (progressTimer !== null) window.clearTimeout(progressTimer);
if (importing) {
state.importing = false;
state.imageImporting = false;
el.createCancel.disabled = false;
state.exportRequestID = 0;
el.cancelExportButton.hidden = true;
el.importCancel.disabled = false;
}
if (openSeq === state.openSeq) {
setBusy(false);
if (refreshManagement) await refreshDocumentManagement();
if (toolbarFocus?.isConnected && !toolbarFocus.disabled) toolbarFocus.focus({ preventScroll: true });
}
}
}
async function refreshFontBinding(doc, openSeq) {
const indices = new Set([state.pageIndex, ...state.visiblePages, ...state.visibleThumbnails]);
for (const shell of el.svgHost.querySelectorAll(".page-shell.rendered")) indices.add(Number(shell.dataset.pageIndex));
state.doc = doc;
resetSearch();
state.documentSelection = null;
resetPageLoading();
state.pageCache.clear();
canvasEditor.clear();
try {
try {
const details = await callWASM("ofdgoDocumentInfo");
if (openSeq !== state.openSeq) return;
Object.assign(doc, details, { detailsPending: false, detailsError: "" });
} catch (err) {
if (openSeq !== state.openSeq) return;
Object.assign(doc, { detailsPending: false, detailsError: err.message });
}
for (const index of indices) {
try {
const page = await loadPageData(index, { openSeq, priority: 0, refresh: true });
if (openSeq !== state.openSeq) return;
if (page) {
mountPageSVG(index, page, openSeq);
updateThumbnail(index, openSeq);
}
} catch (err) {
if (openSeq !== state.openSeq) return;
unmountPage(index);
markFlowPageError(index, err);
markThumbnailError(index);
showError(err, false);
}
}
} finally {
if (openSeq === state.openSeq) {
releasePageResources();
renderMeta(true);
updateControls();
loadDocumentDetails(openSeq);
for (const shell of el.svgHost.children) observeFlowPage(shell);
for (const button of el.pageList.children) observeThumbnail(button, Number(button.dataset.pageIndex), openSeq);
}
}
}
async function refreshEditorPage(doc, index, openSeq, clearSelection = false) {
state.doc = doc;
resetSearch();
state.documentSelection = null;
resetPageLoading();
try {
const page = await loadPageData(index, { openSeq, priority: 0, refresh: true });
if (openSeq !== state.openSeq) {
return;
}
if (clearSelection) {
const pending = canvasEditor.pendingSelection;
canvasEditor.clear();
canvasEditor.pendingSelection = pending;
}
mountPageSVG(index, page, openSeq);
updateThumbnail(index, openSeq);
setStatus(pageStatus(state.pageIndex, doc.pageCount));
} catch (err) {
if (openSeq === state.openSeq) {
state.pageCache.delete(index);
canvasEditor.clear();
const shell = pageShell(index);
shell.querySelector(".page-surface").replaceChildren();
shell.classList.remove("rendered");
markFlowPageError(index, err);
markThumbnailError(index);
}
throw err;
} finally {
if (openSeq === state.openSeq) {
releasePageResources();
renderMeta(true);
updateControls();
loadDocumentDetails(openSeq);
for (const shell of el.svgHost.children) observeFlowPage(shell);
for (const button of el.pageList.children) observeThumbnail(button, Number(button.dataset.pageIndex), openSeq);
}
}
}
function releasePageResources() {
const images = new Set(), fonts = new Set();
for (const page of state.pageCache.values()) {
for (const name of page.imageNames) images.add(name);
for (const font of page.fonts || []) fonts.add(font.name);
}
for (const [name, image] of state.svgImages) {
if (!images.has(name)) {
URL.revokeObjectURL(image.url);
state.svgImages.delete(name);
}
}
for (const [name, font] of state.svgFonts) {
if (!fonts.has(name)) {
document.fonts.delete(font);
state.svgFonts.delete(name);
}
}
}
async function openOFDFile() {
if (document.body.hasAttribute("aria-busy")) {
return;
}
await requestLocalFontsBeforeOpen();
el.ofdInput.value = "";
el.ofdInput.click();
}
async function openOFDFromViewer() {
if (state.doc || document.body.hasAttribute("aria-busy")) {
return;
}
await openOFDFile();
}
async function openOFDFromDrop(event) {
if (!event.dataTransfer.types.includes("Files")) {
return;
}
event.preventDefault();
if (document.body.hasAttribute("aria-busy")) {
return;
}
if (event.dataTransfer.files.length > 1) {
setStatus("一次拖入一个文件");
return;
}
const file = event.dataTransfer.files[0];
if (file && isDocumentFile(file)) {
await requestLocalFontsBeforeOpen();
}
await openOFD(file);
}
function openFontFile(input) {
if (document.body.hasAttribute("aria-busy")) {
return;
}
input.value = "";
input.click();
}
function toggleSidebar(side) {
if (side === "pages") {
state.showPages = !state.showPages;
if (COMPACT_LAYOUT.matches) {
state.showMeta = false;
}
} else if (side === "meta") {
state.showMeta = !state.showMeta;
if (COMPACT_LAYOUT.matches) {
state.showPages = false;
}
}
updateSidebarState();
if (!COMPACT_LAYOUT.matches) {
resizeViewer();
}
}
function updateSidebarState() {
const openingPages = state.showPages && el.pageListPanel.inert;
document.body.toggleAttribute("data-hide-pages", !state.showPages);
document.body.toggleAttribute("data-hide-meta", !state.showMeta);
el.pageListPanel.inert = !state.showPages;
el.metaPanel.inert = !state.showMeta;
el.togglePagesButton.setAttribute("aria-pressed", String(state.showPages));
el.toggleMetaButton.setAttribute("aria-pressed", String(state.showMeta));
if (openingPages) {
updatePageListCurrent(true);
}
}
function syncLayoutMode(event) {
state.showPages = !event.matches;
state.showMeta = !event.matches;
updateSidebarState();
resizeViewer();
}
function resizeViewer() {
if (state.doc) {
syncThumbnailWindow();
applyFit(false);
if (state.fitMode !== "free") {
scrollToPage(state.pageIndex);
}
}
}
async function boot() {
const openSeq = state.openSeq;
setBusy(true, "正在准备引擎", 8, STATUS.engine);
try {
if (!await prepareOffline()) {
return;
}
if (openSeq === state.openSeq) setProgress("正在准备引擎", 8, STATUS.engine);
await ensureWASM();
let fontsRestored = true;
try {
await fontManager.restore();
} catch {
fontsRestored = false;
}
if (openSeq === state.openSeq) setProgress("引擎准备完成", 70);
await loadExportFormats();
if (openSeq === state.openSeq) setEmpty("选择 OFD、PDF 文件");
updateFontSummary();
renderFontList();
updateControls();
updateLocalFontButton();
await fontManager.refreshPermission();
if (openSeq === state.openSeq) {
setStatus(fontsRestored ? STATUS.ready : "字体读取失败");
setBusy(false);
}
if ("launchQueue" in window) {
let opening = Promise.resolve();
window.launchQueue.setConsumer(({ files }) => {
if (!files.length) {
return;
}
opening = opening.then(async () => {
await openOFD(await files[0].getFile());
}).catch((err) => showError(err, !state.doc));
});
}
try {
const remote = remoteDocumentOptions(window.location.hash);
if (remote && openSeq === state.openSeq) await openOFD({ name: remote.name }, remote);
} catch (err) {
if (openSeq === state.openSeq) showError(err, !state.doc);
}
} catch (err) {
if (openSeq === state.openSeq) {
setStatus("引擎加载失败");
setEmpty(String(err.message || err));
setBusy(false);
}
return;
}
}
async function registerOffline() {
const scope = new URL("./", location.href).href;
let registration = await navigator.serviceWorker.getRegistration(scope);
if (!registration || registration.scope !== scope) {
registration = await navigator.serviceWorker.register("./ofdgo_work.js", { updateViaCache: "none" });
}
const worker = registration.active || registration.installing || registration.waiting;
await waitForWorker(worker, "activated");
return registration;
}
async function prepareOffline() {
if (!window.isSecureContext || !("serviceWorker" in navigator) || !("caches" in window) || !("locks" in navigator)) {
return true;
}
el.appPanel.hidden = false;
setProgress("正在准备离线", null, "正在准备离线");
try {
const registration = await registerOffline();
const result = await requestOffline(registration.active, "prepare");
if (result.reload) {
location.reload();
return false;
}
if (navigator.storage?.persist) {
navigator.storage.persist().catch(() => false);
}
el.offlineStatus.textContent = "可离线";
watchApplicationUpdates(result.checksum);
} catch {
el.offlineStatus.textContent = "未就绪";
}
el.refreshAppButton.disabled = false;
return true;
}
function watchApplicationUpdates(checksum) {
let checking = false;
async function check() {
if (checking || document.hidden || !navigator.onLine) {
return;
}
checking = true;
try {
const response = await fetch("./", { method: "HEAD", cache: "no-store", signal: AbortSignal.timeout(10000) });
const latest = response.headers.get("X-OFDGo-Checksum");
if (response.ok && !response.redirected && latest) {
el.offlineStatus.textContent = latest === checksum ? "可离线" : "可更新";
}
} catch {
} finally {
checking = false;
}
}
window.addEventListener("online", check);
document.addEventListener("visibilitychange", check);
check();
}
function waitForWorker(worker, target) {
return new Promise((resolve, reject) => {
function changed() {
if (worker.state === target || worker.state === "activated") {
worker.removeEventListener("statechange", changed);
resolve();
} else if (worker.state === "redundant") {
worker.removeEventListener("statechange", changed);
reject(new Error("离线启动失败"));
}
}
worker.addEventListener("statechange", changed);
changed();
});
}
function requestOffline(worker, type) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
function close() {
channel.port1.close();
worker.removeEventListener("statechange", changed);
}
function changed() {
if (worker.state === "redundant") {
close();
reject(new Error("离线服务中断"));
}
}
channel.port1.onmessage = ({ data }) => {
close();
if (data.ok) {
resolve(data);
} else {
reject(new Error(data.error));
}
};
worker.addEventListener("statechange", changed);
worker.postMessage({ type }, [channel.port2]);
changed();
});
}
async function refreshApplication() {
if (document.body.hasAttribute("aria-busy")) {
return;
}
const temporaryFonts = fontManager.userFonts.some((font) => font.source === "upload");
const message = [state.dirty ? "文档尚未保存,刷新将丢失更改" : "刷新后需重新打开文件", temporaryFonts ? "未保存字体需重新添加" : ""].filter(Boolean).join("\n");
if ((state.ofdBytes || state.doc || temporaryFonts) && !window.confirm(message)) {
return;
}
setBusy(true, "正在刷新应用", null, "正在刷新应用");
el.refreshAppButton.disabled = true;
const panels = document.querySelectorAll(".toolbar, .workspace");
panels.forEach((panel) => { panel.inert = true; });
try {
if (navigator.storage?.persist) {
await navigator.storage.persist().catch(() => false);
}
const scope = new URL("./", location.href).href;
await navigator.locks.request(`ofdgo:${scope}:refresh`, { ifAvailable: true }, async (lock) => {
if (!lock) {
throw new Error("应用正在刷新");
}
const registration = await registerOffline();
await registration.update();
if (registration.installing) {
await waitForWorker(registration.installing, "installed");
}
const worker = registration.waiting || registration.active;
await requestOffline(worker, "refresh");
await waitForWorker(worker, "activated");
window.removeEventListener("beforeunload", warnUnsaved);
location.reload();
});
} catch (err) {
setStatus(err.message === "应用正在刷新" ? err.message : "应用刷新失败");
} finally {
panels.forEach((panel) => { panel.inert = false; });
el.refreshAppButton.disabled = false;
setBusy(false);
}
}
async function loadExportFormats() {
batch.formats = await callWASM("ofdgoOutputFormats");
batchElements.Format.replaceChildren(...batch.formats.map(format => new Option(format.label, format.value)));
updateBatchControls();
const formats = await callWASM("ofdgoExportFormats") || [];
state.exportFormats = formats;
el.exportFormat.replaceChildren();
for (const format of formats) {
const option = document.createElement("option");
option.value = format.value;
option.textContent = format.label;
el.exportFormat.append(option);
}
if (formats.length) {
el.exportFormat.value = formats[0].value;
}
updateDPIControl();
}
async function ensureWASM() {
if (state.ready && !state.wasmExited) {
return;
}
if (!wasmPromise) {
wasmPromise = loadWASM().finally(() => {
wasmPromise = null;
});
}
await wasmPromise;
}
async function loadWASM() {
const wasmSeq = state.wasmSeq + 1;
state.wasmSeq = wasmSeq;
state.ready = false;
state.wasmExited = false;
await new Promise((resolve, reject) => {
const worker = new Worker("./ofdgo_wasm.js");
wasmWorker = worker;
const fail = (err) => {
markWASMExited(wasmSeq, err);
reject(err);
};
worker.onerror = (event) => fail(new Error(event.message || "引擎加载失败"));
worker.onmessageerror = () => fail(new Error("引擎通信失败"));
worker.onmessage = async ({ data }) => {
if (worker !== wasmWorker) {
return;
}
if (data.type === "font") {
try {
const request = wasmRequests.get(data.id);
const font = request?.localFonts?.get(data.name);
if (!font || request.openSeq !== state.openSeq) throw new Error("系统字体不可用");
if (!request.fontData.has(font)) request.fontData.set(font, fontManager.read(font));
const bytes = await request.fontData.get(font);
if (worker === wasmWorker) worker.postMessage({ type: "font", request: data.request, bytes });
} catch (err) {
if (worker === wasmWorker) worker.postMessage({ type: "font", request: data.request, error: err.message });
}
return;
}
if (data.type === "progress") {
setProgress(data.text, data.percent);
} else if (data.type === "conversion") {
if (wasmRequests.get(data.id)?.openSeq === state.openSeq && !el.cancelExportButton.disabled) {
if (data.phase === "open") setProgress("正在读取文档", null);
else if (data.phase === "pages") setProgress(data.completed ? `正在整理页面 · ${data.completed} 页` : "正在整理页面", null);
else if (data.phase === "convert") setProgress(`正在转换页面 · ${data.completed} / ${data.total} 页`, data.total ? 28 + data.completed / data.total * 22 : null);
else if (data.phase === "write.fonts") setProgress("正在处理字体", null);
else setProgress("正在生成文档", null);
}
} else if (data.type === "export") {
if (wasmRequests.get(data.id)?.openSeq === state.openSeq && state.exporting) {
if (data.stage === "save") {
el.cancelExportButton.disabled = true;
if (state.signing) el.signCancel.disabled = true;
setProgress("正在保存文件", null);
} else if (!el.cancelExportButton.disabled) {
if (data.stage === "prepare") {
const label = { snapshot: "正在准备文档", ids: "正在检查标识", commit: "正在整理文档", fonts: "正在处理字体", pages: "正在处理页面", references: "正在检查引用", resources: "正在整理资源", compress: "正在压缩文件", write: "正在写入文件", sign: "正在签署文档", encrypt: "正在加密文档" }[data.phase];
setProgress(data.phase === "compress" && data.completed ? `${label} · 已处理 ${data.completed} 项` : label, data.total ? data.completed / data.total * 100 : null);
} else if (data.completed === data.total) {
setProgress("正在整理文件", null);
} else {
setProgress(`正在导出页面 · ${data.completed} / ${data.total} 页`, data.completed / data.total * 100);
}
}
}
} else if (data.type === "import") {
if (wasmRequests.get(data.id)?.openSeq === state.openSeq && state.imageImporting) {
const text = data.phase === "commit" ? "正在完成导入" : `正在导入图片 · ${data.completed} / ${data.total} 张`;
el.createStatus.textContent = text;
setProgress(text, data.total ? data.completed / data.total * 100 : null);
if (data.phase === "commit") el.createCancel.disabled = el.cancelExportButton.disabled = true;
return;
}
if (wasmRequests.get(data.id)?.openSeq === state.openSeq && state.importing && !el.importCancel.disabled) {
const label = { ids: "正在检查标识", pages: "正在导入页面", resources: "正在读取资源", commit: "正在完成导入" }[data.phase];
el.importStatus.textContent = data.total ? `${label} · ${data.completed} / ${data.total} 页` : label;
setProgress(label, data.total ? data.completed / data.total * 100 : null);
if (data.phase === "commit") el.importCancel.disabled = el.cancelExportButton.disabled = true;
}
} else if (data.type === "ready") {
state.ready = true;
resolve();
} else if (data.type === "exit") {
fail(new Error(data.error));
} else {
const request = wasmRequests.get(data.id);
wasmRequests.delete(data.id);
if (data.ok) {
request.resolve(data.data);
} else {
const err = new Error(missingGlyphMessage(data.missingGlyphs) || (data.reasonCode === "layoutRequired" ? "跨段或换行修改需先设置段落排版" : data.error));
err.code = data.code;
if (data.canceled) {
err.name = "AbortError";
}
request.reject(err);
}
}
};
});
}
function markWASMExited(wasmSeq = state.wasmSeq, err) {
if (wasmSeq !== state.wasmSeq) {
return;
}
state.ready = false;
clearSecurityForms();
state.wasmExited = true;
wasmWorker?.terminate();
wasmWorker = null;
for (const request of wasmRequests.values()) {
request.reject(err || new Error("引擎运行中断"));
}
wasmRequests.clear();
if (err) {
setStatus(state.editorInfo ? "引擎中断,未保存内容无法恢复" : "引擎运行中断");
}
updateControls();
scheduleWASMRecovery();
}
function scheduleWASMRecovery() {
if (!state.ofdBytes || state.wasmRecovering || state.wasmRecoveries >= 2) {
return;
}
window.clearTimeout(wasmRecoveryTimer);
wasmRecoveryTimer = window.setTimeout(recoverWASM, 0);
}
async function recoverWASM() {
if (state.wasmRecovering || !state.ofdBytes) {
return;
}
state.wasmRecovering = true;
state.wasmRecoveries += 1;
const openSeq = ++state.openSeq;
setBusy(true, "正在恢复引擎", 18, STATUS.recovering);
try {
await openDocument({
pageIndex: state.pageIndex,
versionID: state.doc?.documentVersion,
fitMode: state.fitMode || "width",
scale: state.scale,
skipAutoFonts: true,
openSeq,
});
} catch (err) {
if (openSeq === state.openSeq) {
showError(err, !state.doc);
}
} finally {
state.wasmRecovering = false;
if (openSeq === state.openSeq) {
setBusy(false);
}
}
}
function remoteDocumentOptions(hash) {
const params = new URLSearchParams(hash.replace(/^#/, ""));
for (const key of ["url", "name", "page"]) {
if (params.getAll(key).length > 1) throw new Error(`重复的链接参数:${key}`);
}
if (!params.has("url")) return null;
let url;
try { url = new URL(params.get("url")); } catch { throw new Error("文件地址无效"); }
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw new Error("文件地址必须为不含账号密码的 HTTP 或 HTTPS 地址");
if (window.location.protocol === "https:" && url.protocol !== "https:") throw new Error("请使用 HTTPS 文件地址");
const page = params.get("page") || "1";
return { url: url.href, name: params.get("name") || "", pageIndex: /^[1-9]\d*$/.test(page) ? Math.min(Number(page), Number.MAX_SAFE_INTEGER) - 1 : 0 };
}
function remoteDocumentName(name) {
return String(name || "").split(/[\\/]/).pop().replace(/[\u0000-\u001f\u007f]/g, "").trim();
}
async function downloadRemoteDocument(options) {
const controller = new AbortController();
remoteDownload = controller;
el.cancelExportButton.hidden = false;
el.cancelExportButton.disabled = false;
el.cancelExportButton.title = "取消下载";
el.cancelExportButton.setAttribute("aria-label", "取消下载");
setProgress("正在下载文档", null);
try {
const response = await fetch(options.url, { mode: "cors", credentials: "omit", referrerPolicy: "no-referrer", cache: "no-store", signal: controller.signal });
if (!response.ok) throw new Error(`文件下载失败(HTTP ${response.status})`);
const total = Number(response.headers.get("Content-Length")) || 0;
if (!response.body) throw new Error("下载内容为空");
const chunks = [];
let received = 0;
const reader = response.body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.byteLength;
setProgress(`正在下载文档 · ${(received / 1048576).toFixed(1)} MB`, total > 0 && received <= total ? received / total * 20 : null);
}
} finally { reader.releaseLock(); }
controller.signal.throwIfAborted();
const disposition = response.headers.get("Content-Disposition") || "";
let supplied = /(?:^|;)\s*filename\*=UTF-8'[^']*'([^;]+)/i.exec(disposition)?.[1];
if (supplied) { try { supplied = decodeURIComponent(supplied.trim()); } catch { supplied = ""; } }
if (!supplied) supplied = /(?:^|;)\s*filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;]*))/i.exec(disposition)?.slice(1).find(value => value !== undefined)?.replace(/\\(.)/g, "$1");
let pathName = new URL(response.url || options.url).pathname.split("/").pop();
try { pathName = decodeURIComponent(pathName); } catch { pathName = ""; }
const name = remoteDocumentName(options.name) || remoteDocumentName(supplied) || remoteDocumentName(pathName);
return { file: new Blob(chunks), name };
} catch (err) {
if (err instanceof TypeError) throw new Error("下载失败,请检查网络、链接及跨域配置");
throw err;
} finally {
if (remoteDownload === controller) {
remoteDownload = null;
el.cancelExportButton.hidden = true;
}
}
}
async function openOFD(file, remote = null) {
if (!file) {
return;
}
if (!remote && !isDocumentFile(file)) {
el.ofdInput.value = "";
showError(new Error("选择 OFD、PDF 文件"), !state.doc);
return;
}
if (!discardChanges()) {
return;
}
remoteDownload?.abort();
resourceElements.Panel.close();
clearSecurityForms();
state.wasmRecoveries = 0;
const openSeq = ++state.openSeq;
setBusy(true, "正在读取文档", 10, STATUS.opening);
try {
const downloaded = remote ? await downloadRemoteDocument(remote) : null;
if (openSeq !== state.openSeq) return;
await ensureWASM();
if (openSeq !== state.openSeq) return;
const source = downloaded ? downloaded.file : file;
const format = await callWASM("ofdgoDetectFormat", source);
if (openSeq !== state.openSeq) return;
let bytes = new Uint8Array(await source.arrayBuffer());
const name = (downloaded ? downloaded.name : file.name) || `ofdgo.${format}`;
let warnings = [];
let convertedDoc;
if (openSeq !== state.openSeq) {
return;
}
if (format === "pdf") {
setProgress("正在转换文档", 25);
await ensureWASM();
if (openSeq !== state.openSeq) return;
let converted;
try {
converted = await openWithCredentials(await fontManager.files(fontManager.records()), openSeq, bytes, false, true);
} finally {
if (openSeq === state.openSeq) {
state.exportRequestID = 0;
el.cancelExportButton.hidden = true;
}
}
if (openSeq !== state.openSeq) return;
bytes = converted.bytes;
convertedDoc = converted.doc;
warnings = JSON.parse(converted.warnings || "null") || [];
}
await ensureWASM();
if (openSeq !== state.openSeq) return;
const doc = convertedDoc || await openWithCredentials(await fontManager.files(fontManager.userFonts), openSeq, bytes);
if (openSeq !== state.openSeq) return;
state.ofdBytes = bytes;
state.conversionWarnings = warnings;
state.fileName = (name || "ofdgo.ofd").replace(/\.pdf$/i, ".ofd");
if (remote && !/\.ofd$/i.test(state.fileName)) state.fileName += ".ofd";
state.editing = false;
state.composite = null;
state.annotationCreate = null;
state.pageSelection.clear();
state.pageSelectionAnchor = null;
state.pageMultiSelect = false;
state.objectClipboard = null;
state.styleClipboard = null;
state.editorInfo = null;
state.editorViews.clear();
state.fontRenderPending = false;
state.savedRevision = null;
state.packageDirty = false;
state.encryptionDirty = false;
canvasEditor.clear();
setDirty(false);
el.createPanel.close();
el.insertPanel.close();
fontBindingElements.Panel.close();
el.pagePanel.close();
el.paragraphPanel.close();
el.infoPanel.close();
el.documentPanel.close();
state.documentManagement = null;
el.versionPanel.close();
state.versionManagement = null;
state.infoDocumentIndex = null;
el.annotationNote.close();
el.annotationCreate.close();
el.objectPicker.close();
el.importPanel.close();
el.batchPagesPanel.close();
el.objectStylePanel.close();
el.sourceTextPanel.close();
el.objectBoundsPanel.close();
el.outlinePanel.close();
el.structureDialog.close();
updateControls();
await openDocument({ doc, pageIndex: remote?.pageIndex || 0, resetScroll: true, openSeq, activateActions: true });
} catch (err) {
if (openSeq === state.openSeq) {
if (err.name === "AbortError") setStatus("打开已取消");
else showError(err, !state.doc);
setBusy(false);
}
}
}
function isDocumentFile(file) {
return /\.(ofd|pdf)$/i.test(file.name || "");
}
async function openSelectedFonts(event) {
if (document.body.hasAttribute("aria-busy")) {
return;
}
const input = event.currentTarget;
const files = Array.from(input.files || []).filter((file) => (
!input.webkitdirectory || /\.(ttf|otf|ttc)$/i.test(file.name)
));
input.value = "";
if (!files.length) {
setStatus("暂无字体文件");
return;
}
setBusy(true, "正在读取字体", 10, STATUS.fonts);
try {
const fonts = [];
for (let i = 0; i < files.length; i += 1) {
setProgress(`正在读取字体 · ${i + 1} / ${files.length}`, 10 + Math.round(i / files.length * 60));
const file = files[i];
fonts.push(fontManager.record(file.name, new Uint8Array(await file.arrayBuffer()), "upload"));
}
const { saved, changed } = await fontManager.add(fonts);
if (changed) {
await applyFontChange();
if (resourceElements.Panel.open) await resourceTask(loadManagedResources);
}
setStatus(saved ? "字体已保存" : "字体未保存,仅本次可用");
} catch (err) {
showError(err, false);
} finally {
setBusy(false);
}
}
async function loadLocalFonts() {
if (document.body.hasAttribute("aria-busy")) {
return;
}
if (!fontManager.canReadLocal()) {
setStatus("浏览器不支持系统字体");
return;
}
setBusy(true, state.doc ? "正在匹配字体" : "正在请求授权", 12, state.doc ? STATUS.fonts : "正在请求授权");
try {
await nextFrame();
const available = await fontManager.queryLocal();
if (!state.doc) {
setStatus(available.length ? `已授权${available.length}种字体` : "暂无系统字体");
return;
}
if (await loadDocumentLocalFonts(available)) {
await applyFontChange();
}
} catch (err) {
if (err && err.name === "NotAllowedError") {
setStatus("系统字体未授权");
} else {
setStatus(String(err.message || err));
}
} finally {
setBusy(false);
}
}
async function requestLocalFontsBeforeOpen() {
if (!fontManager.canReadLocal() || fontManager.catalogLoaded || fontManager.permission === "denied") {
return;
}
setBusy(true, "正在请求授权", 12, "正在请求授权");
try {
const available = await fontManager.queryLocal();
setStatus(available.length ? `已授权${available.length}种字体` : "暂无系统字体");
} catch (err) {
if (err && err.name === "NotAllowedError") {
setStatus("系统字体未授权");
return;
}
setStatus(String(err.message || err));
} finally {
setBusy(false);
}
}
async function autoLoadDocumentLocalFonts(openSeq) {
if (!state.doc?.fonts?.some((font) => !font.embedded) || !fontManager.canReadLocal() || fontManager.permission === "denied") {
return false;
}
setProgress("正在匹配字体", 62, STATUS.fonts);
try {
const available = fontManager.catalogLoaded ? fontManager.catalog : await fontManager.queryLocal();
if (openSeq !== state.openSeq) {
return false;
}
return await loadDocumentLocalFonts(available, openSeq);
} catch (err) {
if (err && err.name === "NotAllowedError") {
setStatus("系统字体未授权");
return false;
}
setStatus(String(err.message || err));
return false;
}
}
async function loadDocumentLocalFonts(available, openSeq = state.openSeq) {
const docFonts = state.doc?.fonts || [];
if (!docFonts.length) {
fontManager.localFonts = [];
setStatus("暂无文档字体");
updateFontSummary();
renderFontList();
return false;
}
if (docFonts.every((font) => font.embedded)) {
fontManager.localFonts = [];
setStatus("字体均已内嵌");
updateFontSummary();
renderFontList();
return false;
}
const selected = await selectLocalFonts(available);
if (openSeq !== state.openSeq) {
return false;
}
const emptyStatus = available.length === 0 ? "暂无系统字体" : "暂无匹配字体";
const fonts = [];
for (let i = 0; i < selected.length; i += 1) {
setProgress(`正在读取字体 · ${i + 1} / ${selected.length}`, 20 + Math.round(i / selected.length * 60));
const item = selected[i];
const data = await fontManager.read(item);
if (openSeq !== state.openSeq) {
return false;
}
fonts.push(fontManager.record(fontManager.localName(item), data, "browser"));
}
fontManager.localFonts = fonts;
setStatus(fonts.length ? `已加载${fonts.length}种字体` : emptyStatus);
updateFontSummary();
renderFontList();
return fonts.length > 0;
}
async function selectLocalFonts(fonts) {
const available = fontManager.localEntries(fonts);
const matched = await callWASM("ofdgoMatchFontFiles", [...available.keys()]);
return [...new Set(matched.map((name) => available.get(name)))];
}
function scheduleFontSync() {
state.fontSyncPending = true;
syncUserFonts();
}
async function syncUserFonts() {
if (!state.ready || !state.fontSyncPending || state.fontSyncing || document.hidden || document.body.hasAttribute("aria-busy") || document.activeElement?.classList.contains("font-name-input")) {
return;
}
state.fontSyncPending = false;
state.fontSyncing = true;
setBusy(true, "正在同步字体", null);
try {
if (await fontManager.restore()) {
await applyFontChange();
}
if (!state.doc) {
setStatus(STATUS.ready);
}
} catch {
setStatus("字体读取失败");
} finally {
state.fontSyncing = false;
setBusy(false);
}
}
function updateFontSummary() {
const fonts = fontManager.records();
const total = fonts.length;
const enabled = fonts.filter((font) => font.enabled).length;
el.availableFontSummary.textContent = total ? `${enabled}/${total}` : "0";
}
async function applyFontChange() {
updateFontSummary();
renderFontList();
if (!state.doc) {
return;
}
if (canvasEditor.input || canvasEditor.crop) {
const openSeq = state.openSeq;
const fonts = await fontManager.files(fontManager.records());
if (openSeq !== state.openSeq) return;
await callWASM("ofdgoConfigure", fonts, state.renderAnnotations);
if (openSeq !== state.openSeq) return;
state.fontRenderPending = true;
setStatus(pageStatus(state.pageIndex, state.doc.pageCount));
return;
}
await refreshFontPreview(true);
}
async function refreshFontPreview(fontsChanged) {
state.fontRenderPending = false;
await openDocument({
pageIndex: state.pageIndex,
fitMode: state.fitMode,
scale: state.scale,
skipAutoFonts: true,
reuseSession: true,
fontsChanged,
keepPreview: Boolean(state.editorInfo),
previewScroll: { scrollLeft: el.viewerPanel.scrollLeft, scrollTop: el.viewerPanel.scrollTop },
});
}
async function refreshPendingFonts() {
if (!state.fontRenderPending || !state.ready || document.body.hasAttribute("aria-busy") || canvasEditor.input || canvasEditor.crop) return;
await refreshFontPreview(false);
}
async function toggleAnnotations() {
if (document.body.hasAttribute("aria-busy")) {
return;
}
state.renderAnnotations = !state.renderAnnotations;
updateAnnotationButton();
if (!state.doc) {
return;
}
await openDocument({
pageIndex: state.pageIndex,
fitMode: state.fitMode,
skipAutoFonts: true,
reuseSession: true,
});
}
function displayMode() {
return state.editing ? "svg" : state.renderMode;
}
function updateDisplayControls() {
const disabled = !state.ready || !state.doc || state.exporting || document.body.hasAttribute("aria-busy");
el.renderVectorButton.disabled = el.renderRasterButton.disabled = disabled || state.editing;
el.renderVectorButton.setAttribute("aria-pressed", String(displayMode() === "svg"));
el.renderRasterButton.setAttribute("aria-pressed", String(displayMode() === "raster"));
el.renderVectorButton.title = state.editing ? "编辑使用矢量显示" : "矢量显示";
el.renderRasterButton.title = state.editing ? "阅读时可用" : "位图显示";
updateDPIControl();
}
async function refreshPageDisplay(anchor = scaleAnchor(0)) {
const selection = state.editing ? { index: canvasEditor.selected?.index ?? state.pageIndex, ids: canvasEditor.items().map(item => item.id) } : null;
await openDocument({
pageIndex: state.pageIndex, fitMode: state.fitMode, scale: state.scale,
skipAutoFonts: true, reuseSession: true, keepPreview: true, keepSearch: true,
previewAnchor: anchor, selection, throwError: true,
});
}
async function changeDisplayMode(mode = state.renderMode) {
if (document.body.hasAttribute("aria-busy")) {
el.imageDPI.value = String(state.renderDPI);
updateDPIControl();
return;
}
if (state.editing) mode = state.renderMode;
const dpi = currentImageDPI();
if (state.renderDPI === dpi && state.renderMode === mode) return;
const previous = { dpi: state.renderDPI, mode: state.renderMode };
state.renderDPI = dpi;
state.renderMode = mode;
updateDisplayControls();
if (!state.doc || state.editing || (previous.mode === mode && mode === "svg")) return;
try {
await refreshPageDisplay();
} catch (err) {
state.renderDPI = previous.dpi;
state.renderMode = previous.mode;
el.imageDPI.value = String(previous.dpi);
resetPageLoading();
state.pageCache.clear();
updateDisplayControls();
showError(err, false);
}
}
async function changeFont(font, changes) {
if (document.body.hasAttribute("aria-busy")) {
return;
}
setBusy(true, "正在更新字体", null, STATUS.fonts);
try {
await fontManager.change(font, changes);
await applyFontChange();
if (!state.doc) {
setStatus(STATUS.ready);
}
} catch {
renderFontList();
setStatus("字体更新失败");
} finally {
setBusy(false);
}
}
function renderFontList() {
updateTextFonts(canvasEditor.selected, true);
el.fontList.replaceChildren();
const fonts = fontManager.records();
if (!fonts.length) {
const empty = document.createElement("div");
empty.className = "font-empty";
empty.textContent = "暂无字体";
el.fontList.append(empty);
return;
}
const fragment = document.createDocumentFragment();
for (const font of fonts) {
const row = document.createElement("div");
row.className = "font-row";
row.dataset.fontId = font.id;
const source = document.createElement("span");
source.className = "font-source";
source.textContent = fontSourceText(font.source);
const enabled = document.createElement("input");
enabled.type = "checkbox";
enabled.checked = font.enabled;
enabled.title = "启用字体";
const enabledText = document.createElement("span");
enabledText.textContent = font.enabled ? "启用" : "停用";
enabled.addEventListener("change", async () => {
await changeFont(font, { enabled: enabled.checked });
});
const name = document.createElement("input");
name.className = "font-name-input";
name.value = font.name;
name.title = "字体文件名或匹配名";
name.addEventListener("change", async () => {
const next = name.value.trim();
if (!next) {
name.value = font.name;
return;
}
await changeFont(font, { name: next });
});
const main = document.createElement("div");
main.className = "font-main";
main.append(source, name);
const toggle = document.createElement("label");
toggle.className = "font-toggle";
toggle.append(enabled, enabledText);
const remove = document.createElement("button");
remove.className = "font-delete";
remove.type = "button";
remove.title = "删除字体";
remove.textContent = "×";
remove.addEventListener("click", async () => {
await changeFont(font);
});
const actions = document.createElement("div");
actions.className = "font-actions";
actions.append(toggle, remove);
row.append(main, actions);
fragment.append(row);
}
el.fontList.append(fragment);
}
function fontSourceText(source) {
switch (source) {
case "browser":
return "系统";
default:
return "上传";
}
}
function updateLocalFontButton() {
const supported = fontManager.canReadLocal();
el.localFontButton.disabled = !supported;
el.localFontButton.title = supported ? "读取系统字体" : "浏览器不支持系统字体";
updateFontPermissionHint();
}
function updateFontPermissionHint() {
el.fontPermissionHint.hidden = !fontManager.canReadLocal() || fontManager.permission === "granted";
}
function clearSecurityForms() {
finishResourcePick(false);
if (sealPreview) finishSealPosition(false);
finishCredentials(null);
clearEncryptionForm();
el.encryptionPanel.close();
el.signKey.value = "";
el.signKeyPassword.value = "";
if (el.signPanel.open) el.signPanel.close();
if (el.verifyPanel.open) el.verifyPanel.close();
}
function updateCredentialsType() {
const certificate = el.credentialsType.value === "certificate";
el.credentialsStatus.textContent = "";
el.credentialsPasswordRow.hidden = certificate;
el.credentialsCertificateRow.hidden = el.credentialsKeyRow.hidden = !certificate;
el.credentialsKeyPasswordRow.hidden = !certificate;
el.credentialsPassword.required = !certificate;
el.credentialsCertificate.required = el.credentialsKey.required = certificate;
el.credentialsPassword.value = "";
el.credentialsCertificate.value = el.credentialsKey.value = "";
el.credentialsKeyPassword.value = "";
}
function finishCredentials(credentials) {
const request = credentialRequest;
credentialRequest = null;
el.credentialsPassword.value = "";
el.credentialsUser.value = "";
el.credentialsCertificate.value = el.credentialsKey.value = "";
el.credentialsKeyPassword.value = "";
if (el.credentialsPanel.open) el.credentialsPanel.close();
if (request && request.openSeq === state.openSeq) request.resolve(credentials);
else {
credentials?.password.fill(0);
credentials?.key?.fill(0);
credentials?.keyPassword?.fill(0);
request?.resolve(null);
}
}
function requestCredentials(code, openSeq, pdf = false) {
finishCredentials(null);
el.credentialsTypeRow.hidden = pdf;
el.credentialsUserRow.hidden = pdf;
if (pdf) {
el.credentialsType.value = "password";
updateCredentialsType();
}
el.credentialsStatus.textContent = code === "invalidCredentials" ? "解锁失败,请重试" : "";
el.progressPanel.hidden = true;
return new Promise(resolve => {
credentialRequest = { resolve, openSeq };
el.credentialsPanel.showModal();
(el.credentialsType.value === "certificate" ? el.credentialsCertificate : el.credentialsPassword).focus();
});
}
async function openWithCredentials(fonts, openSeq, data = state.ofdBytes, importing = false, pdf = false) {
let credentials = null;
try {
while (openSeq === state.openSeq) {
try {
if (pdf) {
const converted = await callWASM("ofdgoConvertPDF", data, fonts, credentials?.password || null);
if (openSeq !== state.openSeq) return converted;
converted.doc = await callWASM("ofdgoOpen", converted.bytes, fonts, state.renderAnnotations, credentials);
return converted;
}
return importing ? await callWASM("ofdgoLoadImport", data.slice(), credentials)
: await callWASM("ofdgoOpen", data, fonts, state.renderAnnotations, credentials);
} catch (err) {
if (openSeq !== state.openSeq || !(pdf ? err.code === "pdfPassword" : ["credentialsRequired", "invalidCredentials"].includes(err.code))) throw err;
const code = pdf ? credentials ? "invalidCredentials" : "credentialsRequired" : err.code;
credentials?.password.fill(0);
credentials?.key?.fill(0);
credentials?.keyPassword?.fill(0);
credentials = await requestCredentials(code, openSeq, pdf);
if (!credentials || openSeq !== state.openSeq) {
const canceled = new Error("打开已取消");
canceled.name = "AbortError";
throw canceled;
}
setProgress(pdf ? "正在读取文档" : "正在解析文档", null);
el.progressPanel.hidden = false;
}
}
const canceled = new Error("打开已取消");
canceled.name = "AbortError";
throw canceled;
} finally {
credentials?.password.fill(0);
credentials?.key?.fill(0);
credentials?.keyPassword?.fill(0);
}
}
async function openDocument(options = {}) {
if (!state.ofdBytes && !state.editorInfo) {
return;
}
const openSeq = options.openSeq || (state.openSeq += 1);
const previewPosition = options.previewScroll && state.pageWindow ? pageWindowAnchor(state.pageWindow) : null;
const viewport = options.keepPreview ? el.viewerPanel.getBoundingClientRect() : null;
const previewIDs = viewport ? [...state.visiblePages].filter((index) => {
const bounds = pageShell(index)?.getBoundingClientRect();
return bounds && bounds.bottom > viewport.top && bounds.top < viewport.bottom;
}).map((index) => state.doc.pages[index].id) : [];
const thumbnailPreviews = options.preserveThumbnails ? new Map([...state.pageCache.values()]
.filter((page) => page.id !== state.doc.pages[state.pageIndex].id).map((page) => [page.id, page])) : null;
if (!state.ready || state.wasmExited) {
await ensureWASM();
}
if (openSeq !== state.openSeq) {
return;
}
const resetLocalFonts = !options.skipAutoFonts;
if (!options.keepSearch) resetSearch();
if (resetLocalFonts) {
fontManager.localFonts = [];
updateFontSummary();
renderFontList();
}
options.keepPreview ? setBusy(true) : setBusy(true, "正在打开文档", 20, STATUS.opening);
try {
if (!options.keepPreview) {
setProgress("正在解析文档", 52);
await nextFrame();
}
if (openSeq !== state.openSeq) {
return;
}
const fonts = !options.doc && (!options.reuseSession || options.fontsChanged)
? await fontManager.files(resetLocalFonts ? fontManager.userFonts : fontManager.records()) : null;
if (openSeq !== state.openSeq) {
return;
}
let doc = options.doc || (options.reuseSession
? await callWASM("ofdgoConfigure", fonts, state.renderAnnotations)
: await openWithCredentials(fonts, openSeq));
if (openSeq !== state.openSeq) {
return;
}
if (options.versionID !== undefined && options.versionID !== (doc.documentVersion || "")) {
doc = await callWASM("ofdgoSelectVersion", options.versionID);
if (openSeq !== state.openSeq) return;
}
const pageCount = doc.pageCount || 0;
const pageIndex = Math.min(Math.max(options.pageIndex || 0, 0), Math.max(pageCount - 1, 0));
state.doc = doc;
if (options.resetScroll || !options.skipAutoFonts) {
state.pageSelection.clear();
state.pageSelectionAnchor = null;
state.pageMultiSelect = false;
}
state.pageIndex = pageIndex;
state.scale = options.scale || 1;
if (!options.fitMode) {
state.rotation = 0;
setContinuous(window.matchMedia("(max-width: 640px)").matches);
}
state.fitMode = options.fitMode || (!state.continuous && pageCount === 1 ? "height" : "width");
if (!options.skipAutoFonts && await autoLoadDocumentLocalFonts(openSeq)) {
if (openSeq !== state.openSeq) {
return;
}
await openDocument({
pageIndex,
fitMode: state.fitMode,
resetScroll: options.resetScroll,
skipAutoFonts: true,
openSeq,
reuseSession: true,
fontsChanged: true,
activateActions: options.activateActions,
});
return;
}
if (openSeq !== state.openSeq) {
return;
}
if (options.keepPreview) {
resetPageLoading();
state.pageCache.clear();
if (thumbnailPreviews) {
for (const page of doc.pages) {
const preview = thumbnailPreviews.get(page.id);
if (preview) state.pageCache.set(page.index, { ...preview, previewOnly: true });
}
}
const indices = new Set([pageIndex, ...previewIDs.map(id => pageIndexByID(doc.pages, id)).filter(index => index >= 0)]);
for (const index of indices) {
await loadPageData(index, { openSeq, priority: 0, refresh: true });
if (openSeq !== state.openSeq) {
return;
}
}
}
if (options.clearSelection) canvasEditor.clear();
if (options.selection) canvasEditor.pendingSelection = options.selection;
resetPageFlow(options.keepPreview);
renderPageList();
if (options.keepPreview || options.resetScroll || el.outlineList.childElementCount === 0) {
renderOutlines(!options.keepPreview);
}
renderMeta(options.keepPreview);
renderPageFlow();
if (options.keepPreview) {
for (const [index, page] of state.pageCache) {
if (!page.previewOnly) mountPageSVG(index, page, openSeq);
}
releasePageResources();
}
if (options.resetScroll) {
el.viewerPanel.scrollLeft = 0;
el.viewerPanel.scrollTop = 0;
el.navigationContent.scrollLeft = 0;
el.navigationContent.scrollTop = 0;
el.metaPanel.scrollLeft = 0;
el.metaPanel.scrollTop = 0;
}
applyFit(false);
if (options.keepPreview) {
if (options.previewAnchor) {
restoreScaleAnchor(options.previewAnchor);
} else if (options.previewScroll) {
Object.assign(el.viewerPanel, options.previewScroll);
if (previewPosition && state.pageWindow) syncPageWindow(state.pageWindow,
state.pageWindow.offsets[previewPosition.index] + previewPosition.offset);
} else {
scrollToPage(pageIndex);
}
setStatus(pageStatus(pageIndex, pageCount));
}
await nextFrame();
if (openSeq !== state.openSeq) {
return;
}
const page = options.keepPreview ? state.pageCache.get(pageIndex)
: await renderPage(pageIndex, { keepBusy: true, scroll: false, openSeq });
if (page && options.resetScroll) {
canvasEditor.focus();
}
queueNearbyPages(pageIndex, openSeq);
loadDocumentDetails(openSeq);
if (page && options.activateActions) {
state.openActionEnabled = true;
state.openActionDocument = !state.editing;
state.openActionPage = state.editing ? state.doc.pages[state.pageIndex]?.id : null;
state.openActionTask = null;
scheduleOpenActions();
}
} catch (err) {
if (openSeq !== state.openSeq) {
return;
}
if (options.throwError) throw err;
if (!options.reuseSession && !options.doc) {
state.ofdBytes = null;
state.doc = null;
renderSecurity();
updateControls();
}
showError(err, true);
} finally {
if (openSeq === state.openSeq) {
setBusy(false);
}
}
}
async function loadDocumentDetails(openSeq) {
if (openSeq !== state.openSeq || !state.doc) return;
try {
const details = await callWASM("ofdgoDocumentInfo");
if (openSeq !== state.openSeq) return;
Object.assign(state.doc, details, { detailsPending: false, detailsError: "" });
renderMeta();
while (openSeq === state.openSeq) {
await waitForPaint();
if (openSeq !== state.openSeq) return;
const info = await callWASM("ofdgoDocumentInfo", true);
if (openSeq !== state.openSeq) return;
if (!info.detailsPending) {
const knownFonts = new Set((state.doc.fonts || []).map(font => font.id));
const missingFonts = info.fonts?.some(font => font.used > 0 && font.status === "missing" && !knownFonts.has(font.id));
Object.assign(state.doc, info, { detailsPending: false, detailsError: "" });
renderMeta();
if (missingFonts && fontManager.permission === "granted" && !state.editorInfo && !document.body.hasAttribute("aria-busy")) {
setBusy(true);
try {
if (await autoLoadDocumentLocalFonts(openSeq) && openSeq === state.openSeq) await applyFontChange();
} finally {
if (openSeq === state.openSeq) setBusy(false);
}
}
return;
}
}
} catch (err) {
if (openSeq === state.openSeq) {
Object.assign(state.doc, { detailsPending: false, detailsError: err.message });
renderMeta();
showError(err, false);
}
}
}
async function renderPage(index, options = {}) {
if (!state.doc || (state.editorInfo && document.body.hasAttribute("aria-busy") && !options.keepBusy)) {
return null;
}
const openSeq = options.openSeq || state.openSeq;
if (openSeq !== state.openSeq) {
return null;
}
const pageCount = state.doc.pageCount || 0;
if (index < 0 || index >= pageCount) {
return null;
}
const pageSeq = ++state.pageSeq;
if (!state.exporting) {
if (options.keepBusy) {
setProgress("正在渲染页面", 76);
} else {
setBusy(true, "正在渲染页面", 35, "正在渲染页面");
}
}
try {
setCurrentPage(index);
if (options.fit !== false) {
applyFit(false);
}
if (options.scroll !== false) {
scrollToPage(index);
} else if (state.pageWindow && (!pageShell(index) || pageShell(index).style.visibility === "hidden")) {
scrollToPage(index);
}
const page = await renderFlowPage(index, { throwError: true, openSeq, priority: 0 });
if (openSeq !== state.openSeq || index !== state.pageIndex) {
return null;
}
if (options.scroll !== false) {
scrollToPage(index);
}
queueNearbyPages(index, openSeq);
return page;
} catch (err) {
if (openSeq === state.openSeq) {
showError(err, false);
}
return null;
} finally {
if (!options.keepBusy && !state.exporting && openSeq === state.openSeq && pageSeq === state.pageSeq) {
setBusy(false);
if (!options.openAction) scheduleOpenActions();
}
}
}
function settingsField(container, label, id, value = "", type = "text", unit = "") {
const row = document.createElement("label"), title = document.createElement("span");
row.className = "form-row";
title.textContent = label;
const field = document.createElement(type === "textarea" ? "textarea" : "input");
field.id = `settings${id}`;
field.setAttribute("aria-label", label);
if (type === "textarea") field.rows = 3;
else field.type = type;
if (type === "number") field.step = "any";
if (type === "checkbox") field.checked = Boolean(value);
else field.value = value ?? "";
row.append(title, field);
if (unit) {
const wrapper = document.createElement("span"), suffix = document.createElement("span");
wrapper.className = "form-unit";
suffix.textContent = unit;
wrapper.append(field, suffix);
row.append(wrapper);
}
container.append(row);
return field;
}
function settingsSelect(container, label, id, choices, value, change) {
const row = document.createElement("label"), title = document.createElement("span"), field = document.createElement("select");
row.className = "form-row";
title.textContent = label;
field.id = `settings${id}`;
field.setAttribute("aria-label", label);
field.replaceChildren(...choices.map(([value, text]) => new Option(text, String(value))));
if (value != null && choices.some(([key]) => String(key) === String(value))) field.value = String(value);
if (change) field.addEventListener("change", () => change(field.value));
row.append(title, field);
container.append(row);
return field;
}
function settingsButton(label, action, disabled = false, id = "") {
const button = document.createElement("button");
button.type = "button";
button.className = "button";
button.textContent = label;
button.disabled = disabled;
if (id) button.id = `settings${id}`;
button.addEventListener("click", action);
settingsElements.Actions.append(button);
return button;
}
function settingsValue(id) {
const field = document.querySelector(`#settings${id}`);
return field?.type === "checkbox" ? field.checked : field?.value ?? "";
}
async function openDocumentSettings() {
if (!state.doc || !state.ready || state.exporting || document.body.hasAttribute("aria-busy")) return;
settings.page = state.pageIndex;
settings.object = canvasEditor.selected?.index === settings.page && !canvasEditor.selected.scoped ? canvasEditor.selected.id : "";
settings.path = state.outlineSelection || [];
settings.selected = -1;
settings.resource = null;
settingsElements.Kind.disabled = false;
for (const option of settingsElements.Kind.options) option.hidden = option.value === "actions" && !state.editing || ["ColorSpace", "DrawParam"].includes(option.value);
if (settingsElements.Kind.selectedOptions[0]?.hidden) settingsElements.Kind.value = "bookmarks";
settingsElements.Panel.showModal();
await loadDocumentSettings();
}
async function openResourceSettings(kind, id) {
if (!state.doc || !state.ready || document.body.hasAttribute("aria-busy")) return;
settings.resource = {kind, id};
const page = Number(resourceElements.Page.value);
settings.page = resourceElements.Page.value !== "" && Number.isInteger(page) && page >= 0 && page < state.doc.pages.length ? page : state.pageIndex;
settings.object = resourceElements.Object.value || "";
settingsElements.Kind.querySelector(`option[value="${kind}"]`).hidden = false;
settingsElements.Kind.value = kind;
settingsElements.Kind.disabled = true;
settingsElements.Panel.showModal();
await loadDocumentSettings();
}
async function loadDocumentSettings() {
const sequence = ++settings.sequence, openSeq = state.openSeq;
const kind = settingsElements.Kind.value;
settings.busy = true;
settingsElements.Form.inert = true;
settingsElements.Status.textContent = "正在读取信息";
try {
const data = ["bookmarks", "actions", "extensions"].includes(kind) ? await callWASM("ofdgoDocumentNavigation", kind) : {};
if (sequence !== settings.sequence || openSeq !== state.openSeq || !settingsElements.Panel.open) return;
data.resources = ["bookmarks", "extensions"].includes(kind) ? [] : await callWASM("ofdgoResources") || [];
if (sequence !== settings.sequence || openSeq !== state.openSeq || !settingsElements.Panel.open) return;
if (kind === "actions" && state.editing) {
if (settings.scope === "object") {
const page = await loadPageData(settings.page);
if (sequence !== settings.sequence || openSeq !== state.openSeq || !settingsElements.Panel.open) return;
data.objects = (page.objects || []).filter(item => item.type !== "Annotation" && !item.scoped);
if (!data.objects.some(item => item.id === settings.object)) settings.object = data.objects[0]?.id || "";
}
if (settings.scope === "outline" && !settings.path.length) settings.path = outlineSettingsChoices()[0]?.[0] ? JSON.parse(outlineSettingsChoices()[0][0]) : [];
data.actions = (settings.scope === "object" && !settings.object || settings.scope === "outline" && !settings.path.length) ? [] : await callWASM("ofdgoActionList", settings.scope, settings.page, settings.object, settings.path) || [];
}
if (kind === "templates" || kind === "layers") {
data.page = await callWASM("ofdgoPageSettings", settings.page);
if (kind === "layers" && state.editing) {
const page = await loadPageData(settings.page);
data.objects = (page.objects || []).filter(item => item.type !== "Annotation" && !item.scoped);
}
}
if (kind === "ColorSpace" || kind === "DrawParam") {
data.resource = settings.resource.id ? await callWASM("ofdgoStyleResource", kind, settings.resource.id) : {};
if (state.editing) {
const page = await loadPageData(settings.page);
data.objects = (page.objects || []).filter(item => ["TextObject", "PathObject", "CompositeObject", "CompositeGraphicUnit"].includes(item.type) && !item.scoped);
}
}
if (sequence !== settings.sequence || openSeq !== state.openSeq || !settingsElements.Panel.open) return;
settings.data = data;
settings.actions = data.actions || [];
settingsElements.Status.textContent = "";
renderDocumentSettings();
} catch (error) {
if (sequence === settings.sequence && openSeq === state.openSeq) {
settingsElements.Content.replaceChildren();
settingsElements.Actions.replaceChildren();
settingsButton("关闭", () => settingsElements.Panel.close());
settingsElements.Status.textContent = error.message;
}
} finally {
if (sequence === settings.sequence) { settings.busy = false; settingsElements.Form.inert = false; }
}
}
function renderDocumentSettings() {
settingsElements.Context.replaceChildren();
settingsElements.Content.replaceChildren();
settingsElements.Actions.replaceChildren();
const kind = settingsElements.Kind.value;
settingsElements.Title.textContent = {ColorSpace: "色彩设置", DrawParam: "样式设置"}[kind] || "文档配置";
if (kind === "actions") renderActionSettings();
else if (kind === "bookmarks") renderBookmarkSettings();
else if (kind === "extensions") renderExtensionSettings();
else if (kind === "templates") renderTemplateSettings();
else if (kind === "layers") renderLayerSettings();
else if (kind === "ColorSpace" || kind === "DrawParam") renderStyleResourceSettings(kind);
settingsButton("关闭", () => settingsElements.Panel.close(), false, "Close").classList.add("settings-close");
}
function settingsPages(container, id, value, change) {
return settingsSelect(container, "页码", id, state.doc.pages.map((page, index) => [index, `第${index + 1}页`]), value, change);
}
function outlineSettingsChoices() {
const result = [];
const visit = (nodes, path = [], prefix = "") => {
for (const [index, node] of (nodes || []).entries()) {
const next = [...path, index], title = prefix + node.title;
result.push([JSON.stringify(next), title]);
visit(node.children, next, title + " / ");
}
};
visit(state.doc?.outlines);
return result;
}
function actionSettingsLabel(action) {
if (action.Goto) return action.Goto.Bookmark ? `书签 · ${action.Goto.Bookmark.Name}` : `页面 · ${pageIndexByID(state.doc.pages, action.Goto.Dest?.PageID) + 1}`;
if (action.URI) return `网址 · ${action.URI.URI}`;
const [key, label, id] = action.Sound ? ["Audio", "音频", action.Sound.ResourceID] : action.Movie ? ["Video", "视频", action.Movie.ResourceID] : ["Attachment", "附件", action.GotoA?.AttachID];
return `${label} · ${settings.data.resources.find(item => item.kind === key && item.id === referenceIDKey(id))?.name || id || ""}`;
}
function renderActionSettings() {
const context = settingsElements.Context, content = settingsElements.Content;
settingsSelect(context, "归属", "Scope", [["document", "文档"], ["page", "页面"], ["object", "图元"], ["outline", "目录"]], settings.scope, value => { settings.scope = value; settings.selected = -1; loadDocumentSettings(); });
if (settings.scope === "page" || settings.scope === "object") settingsPages(context, "Page", settings.page, value => { settings.page = Number(value); settings.selected = -1; loadDocumentSettings(); });
if (settings.scope === "object") settingsSelect(context, "图元", "Object", (settings.data.objects || []).map(item => [item.id, `${item.id} · ${item.text || ({TextObject: "文字", PathObject: "图形", ImageObject: "图片"}[item.type] || "组合")}`]), settings.object, value => { settings.object = value; settings.selected = -1; loadDocumentSettings(); });
if (settings.scope === "outline") settingsSelect(context, "目录", "Outline", outlineSettingsChoices(), JSON.stringify(settings.path), value => { settings.path = JSON.parse(value); settings.selected = -1; loadDocumentSettings(); });
if (settings.selected >= settings.actions.length) settings.selected = -1;
settingsSelect(context, "动作", "Entry", [[-1, "新增"], ...settings.actions.map((item, index) => [index, `${index + 1} · ${actionSettingsLabel(item)}`])], settings.selected, value => { settings.selected = Number(value); renderDocumentSettings(); });
const action = settings.actions[settings.selected] || {};
const type = action.Goto ? action.Goto.Bookmark ? "Bookmark" : "Goto" : ["URI", "GotoA", "Sound", "Movie"].find(key => action[key]) || "Goto";
settingsSelect(content, "类型", "ActionType", [["Goto", "页面"], ["Bookmark", "书签"], ["URI", "网址"], ["GotoA", "附件"], ["Sound", "音频"], ["Movie", "视频"]], type, value => renderActionFields(value, action));
const fields = document.createElement("div");
fields.id = "settingsActionFields";
content.append(fields);
renderActionFields(type, action);
const missing = settings.scope === "object" && !settings.object || settings.scope === "outline" && !settings.path.length;
settingsButton(settings.selected < 0 ? "添加" : "修改", () => writeActionSettings("save"), missing, "Save");
settingsButton("删除", () => writeActionSettings("delete"), settings.selected < 0, "Delete");
settingsButton("上移", () => writeActionSettings("up"), settings.selected <= 0, "Up");
settingsButton("下移", () => writeActionSettings("down"), settings.selected < 0 || settings.selected >= settings.actions.length - 1, "Down");
}
function renderActionFields(type, action) {
const fields = document.querySelector("#settingsActionFields");
fields.replaceChildren();
if (type === "Goto") renderDestinationFields(fields, action.Goto?.Dest);
else if (type === "Bookmark") settingsSelect(fields, "书签", "Bookmark", (settings.data.bookmarks || []).map(item => [item.Name, item.Name]), action.Goto?.Bookmark?.Name);
else if (type === "URI") {
settingsField(fields, "网址", "URI", action.URI?.URI);
settingsField(fields, "基址", "Base", action.URI?.Base);
settingsField(fields, "目标", "Target", action.URI?.Target);
} else {
const kind = {GotoA: "Attachment", Sound: "Audio", Movie: "Video"}[type];
settingsSelect(fields, "资源", "Resource", settings.data.resources.filter(item => item.kind === kind).map(item => [item.id, item.name]), referenceIDKey(action[type]?.ResourceID || action[type]?.AttachID));
if (type === "GotoA") settingsSelect(fields, "窗口", "NewWindow", [["", "默认"], ["true", "新建"], ["false", "当前"]], action.GotoA?.NewWindow == null ? "" : String(action.GotoA.NewWindow));
if (type === "Sound") {
const volume = settingsField(fields, "音量", "Volume", action.Sound?.Volume ?? "", "number");
volume.min = "0"; volume.max = "100"; volume.step = "1"; volume.placeholder = "默认";
settingsField(fields, "循环", "Repeat", action.Sound?.Repeat, "checkbox");
settingsField(fields, "同步", "Synchronous", action.Sound?.Synchronous, "checkbox");
}
if (type === "Movie") settingsSelect(fields, "操作", "Operator", [["", "默认"], ["Play", "播放"], ["Stop", "停止"], ["Pause", "暂停"], ["Resume", "继续"]], action.Movie?.Operator || "");
}
}
function renderDestinationFields(container, dest = {}) {
const type = dest?.Type || "XYZ";
settingsSelect(container, "定位", "DestType", [["XYZ", "坐标"], ["Fit", "适页"], ["FitH", "适宽"], ["FitV", "适高"], ["FitR", "区域"]], type, () => updateDestinationFields());
const index = pageIndexByID(state.doc.pages, dest?.PageID);
const pages = state.doc.pages.map((page, index) => [page.id, `第${index + 1}页`]);
if (dest?.PageID && index < 0) pages.push([dest.PageID, "页面缺失"]);
settingsSelect(container, "页码", "DestPage", pages, index >= 0 ? state.doc.pages[index].id : dest?.PageID || state.doc.pages[settings.page]?.id);
for (const [id, label] of [["Left", "左侧"], ["Top", "顶部"], ["Right", "右侧"], ["Bottom", "底部"], ["Zoom", "缩放"]]) {
const field = settingsField(container, label, id, dest?.[`Omit${id}`] ? "" : dest?.[id] ?? "", "number");
field.placeholder = id === "Zoom" ? "保持" : "默认";
}
updateDestinationFields();
}
function updateDestinationFields() {
const type = settingsValue("DestType"), required = {XYZ: ["Left", "Top", "Zoom"], Fit: [], FitH: ["Top"], FitV: ["Left"], FitR: ["Left", "Top", "Right", "Bottom"]}[type] || [];
for (const id of ["Left", "Top", "Right", "Bottom", "Zoom"]) {
const field = document.querySelector(`#settings${id}`);
field.closest("label").hidden = !required.includes(id);
field.disabled = !state.editing || !required.includes(id);
}
}
function destinationFromSettings() {
const dest = {Type: settingsValue("DestType"), PageID: settingsValue("DestPage")};
for (const id of ["Left", "Top", "Right", "Bottom", "Zoom"]) dest[id] = Number(settingsValue(id));
for (const id of ["Left", "Top", "Zoom"]) dest[`Omit${id}`] = settingsValue(id) === "";
return dest;
}
async function commitSettings(name, ...args) {
if (!state.editing || settings.busy || document.body.hasAttribute("aria-busy")) return false;
settings.busy = true;
settingsElements.Form.inert = true;
settingsElements.Status.textContent = "正在保存修改";
try {
if (!await changeDocument(name, null, ...args)) {
if (settingsElements.Status.textContent === "正在保存修改") settingsElements.Status.textContent = el.statusText.textContent;
return false;
}
await loadDocumentSettings();
return true;
} finally { settings.busy = false; settingsElements.Form.inert = false; }
}
async function writeActionSettings(operation) {
if (!settingsElements.Form.reportValidity()) return;
const actions = structuredClone(settings.actions), index = settings.selected;
if (operation === "save") {
const original = actions[index] || {};
const action = {Event: {document: "DO", page: "PO", object: "CLICK", outline: original.Event || "CLICK"}[settings.scope], Region: original.Region || null};
const type = settingsValue("ActionType");
if (type === "Goto") action.Goto = {Dest: destinationFromSettings()};
else if (type === "Bookmark") action.Goto = {Bookmark: {Name: settingsValue("Bookmark")}};
else if (type === "URI") action.URI = {URI: settingsValue("URI"), Base: settingsValue("Base"), Target: settingsValue("Target")};
else if (type === "GotoA") action.GotoA = {AttachID: settingsValue("Resource"), NewWindow: settingsValue("NewWindow") === "" ? null : settingsValue("NewWindow") === "true"};
else if (type === "Sound") action.Sound = {ResourceID: settingsValue("Resource"), Volume: settingsValue("Volume") === "" ? null : Number(settingsValue("Volume")), Repeat: settingsValue("Repeat"), Synchronous: settingsValue("Synchronous")};
else action.Movie = {ResourceID: settingsValue("Resource"), Operator: settingsValue("Operator")};
if (index < 0) { settings.selected = actions.length; actions.push(action); }
else actions[index] = action;
} else if (operation === "delete") { actions.splice(index, 1); settings.selected = Math.min(index, actions.length - 1); }
else {
const next = index + (operation === "up" ? -1 : 1);
[actions[index], actions[next]] = [actions[next], actions[index]];
settings.selected = next;
}
if (!await commitSettings("ofdgoChangeActions", settings.scope, settings.page, settings.object, settings.path, JSON.stringify(actions))) settings.selected = index;
}
function renderStyleResourceSettings(kind) {
const content = settingsElements.Content, value = settings.data.resource || {};
const existing = Boolean(settings.resource.id);
if (kind === "ColorSpace") {
settingsSelect(content, "模型", "ColorType", [["RGB", "RGB"], ["GRAY", "GRAY"], ["CMYK", "CMYK"]], value.Type || "RGB");
settingsSelect(content, "位深", "Bits", [1, 2, 4, 8, 16].map(bits => [bits, String(bits)]), value.BitsPerComponent || 8);
settingsField(content, "调色板", "Palette", (value.Palette || []).join("\n"), "textarea");
if (value.Profile) appendInfoLine(content, "配置", value.Profile);
const file = settingsField(content, "配置文件", "Profile", "", "file");
file.accept = ".icc,.icm";
if (value.Profile) settingsField(content, "移除配置", "ClearProfile", false, "checkbox");
} else {
settingsSelect(content, "继承", "Relative", [["", "无"], ...settings.data.resources.filter(item => item.kind === "DrawParam" && item.id !== settings.resource.id).map(item => [item.id, item.name])], referenceIDKey(value.Relative));
settingsSelect(content, "字体", "Font", [["", "继承"], ...settings.data.resources.filter(item => item.kind === "Font").map(item => [item.id, item.name])], referenceIDKey(value.Font));
const grid = document.createElement("div");
grid.className = "settings-grid";
content.append(grid);
for (const [id, label, unit] of [["Size", "字号", "mm"], ["Weight", "字重", ""], ["LineWidth", "线宽", "mm"], ["MiterLimit", "截断值", ""]]) {
const field = settingsField(grid, label, id, value[id] || (value[`${id}Set`] ? 0 : ""), "number", unit);
field.min = "0";
field.placeholder = "继承";
if (id === "Weight") { field.step = "100"; field.max = "900"; }
}
settingsField(content, "斜体", "Italic", value.Italic, "checkbox");
settingsSelect(content, "端点", "Cap", [["", "继承"], ["Butt", "平头"], ["Round", "圆头"], ["Square", "方头"]], value.Cap || "");
settingsSelect(content, "连接", "Join", [["", "继承"], ["Miter", "尖角"], ["Round", "圆角"], ["Bevel", "斜角"]], value.Join || "");
settingsField(content, "虚线", "DashPattern", value.DashPattern || "");
settingsField(content, "偏移", "DashOffset", value.DashOffset ?? "", "number", "mm");
for (const [key, label] of [["FillColor", "填充"], ["StrokeColor", "描边"]]) {
settingsSelect(content, label, key + "Mode", [["keep", "原有"], ["inherit", "继承"], ["solid", "颜色"]], "keep");
const field = settingsField(content, label + "颜色", key, "#000000", "color");
field.closest("label").hidden = true;
document.querySelector(`#settings${key}Mode`).addEventListener("change", event => { field.closest("label").hidden = event.target.value !== "solid"; });
}
}
content.querySelectorAll("input,select,textarea").forEach(field => { field.disabled = !state.editing; });
if (!state.editing) return;
settingsButton(existing ? "修改" : "添加", () => writeStyleResource(kind), false, "Save");
if (!existing) return;
const binding = document.createElement("div");
binding.className = "settings-binding";
content.append(binding);
settingsPages(binding, "TargetPage", settings.page, value => { settings.page = Number(value); loadSettingsObjects(); });
settingsSelect(binding, "图元", "TargetObject", [["", "选择图元"], ...(settings.data.objects || []).map(item => [item.id, `${item.id} · ${item.text || "图元"}`])], settings.object);
if (kind === "ColorSpace") {
settingsSelect(binding, "应用", "PaintTarget", [["fill", "填充"], ["stroke", "描边"]], "fill");
settingsSelect(binding, "方式", "PaintMode", [["value", "分量"], ["index", "索引"]], "value");
settingsField(binding, "颜色值", "PaintValue", value.Type === "CMYK" ? "0 0 0 255" : value.Type === "GRAY" ? "0" : "0 0 0");
}
settingsButton("应用", () => applyStyleResource(kind), false, "Apply");
if (kind === "DrawParam") settingsButton("解绑", () => applyStyleResource(kind, true), false, "Unbind");
}
async function loadSettingsObjects() {
const sequence = settings.sequence, page = settings.page, openSeq = state.openSeq;
const field = document.querySelector("#settingsTargetObject");
field.disabled = true;
try {
const data = await loadPageData(page);
if (sequence !== settings.sequence || page !== settings.page || openSeq !== state.openSeq) return;
field.replaceChildren(new Option("选择图元", ""), ...(data.objects || []).filter(item => ["TextObject", "PathObject", "CompositeObject", "CompositeGraphicUnit"].includes(item.type) && !item.scoped).map(item => new Option(`${item.id} · ${item.text || "图元"}`, item.id)));
} catch (error) { if (sequence === settings.sequence) settingsElements.Status.textContent = error.message; }
finally { if (sequence === settings.sequence) field.disabled = false; }
}
async function writeStyleResource(kind) {
if (!settingsElements.Form.reportValidity()) return;
const sequence = settings.sequence, openSeq = state.openSeq, resource = settings.resource, open = settingsElements.Panel.open;
const value = structuredClone(settings.data.resource || {});
let profile = null;
const colors = {};
if (kind === "ColorSpace") {
value.Type = settingsValue("ColorType");
value.BitsPerComponent = Number(settingsValue("Bits"));
value.Palette = settingsValue("Palette").split(/\r?\n/).map(line => line.trim()).filter(Boolean);
const file = document.querySelector("#settingsProfile").files[0];
if (file) {
try { profile = new Uint8Array(await file.arrayBuffer()); }
catch (error) { if (sequence === settings.sequence && openSeq === state.openSeq) settingsElements.Status.textContent = error.message; return; }
if (sequence !== settings.sequence || openSeq !== state.openSeq || resource !== settings.resource || open !== settingsElements.Panel.open) return;
}
else if (settingsValue("ClearProfile")) profile = new Uint8Array();
} else {
for (const id of ["Relative", "Font", "Cap", "Join", "DashPattern"]) value[id] = settingsValue(id);
for (const id of ["Size", "Weight", "LineWidth", "MiterLimit"]) value[id] = Number(settingsValue(id));
value.LineWidthSet = settingsValue("LineWidth") !== "";
value.MiterLimitSet = settingsValue("MiterLimit") !== "";
value.DashOffset = settingsValue("DashOffset") === "" ? null : Number(settingsValue("DashOffset"));
value.Italic = settingsValue("Italic");
for (const key of ["FillColor", "StrokeColor"]) {
const mode = settingsValue(key + "Mode");
if (mode === "inherit") value[key] = null;
if (mode === "solid") colors[key] = settingsValue(key);
}
}
const before = new Set(settings.data.resources.filter(item => item.kind === kind).map(item => item.id));
if (await commitSettings("ofdgoChangeStyleResource", kind, settings.resource.id, JSON.stringify(value), profile, JSON.stringify(colors))) {
await resourceTask(loadManagedResources);
if (!settings.resource.id) {
settings.resource.id = resourceManager.items.find(item => item.kind === kind && !before.has(item.id))?.id || "";
await loadDocumentSettings();
}
}
}
async function applyStyleResource(kind, clear = false) {
const object = settingsValue("TargetObject");
if (!object) { settingsElements.Status.textContent = "请选择图元"; return; }
settings.object = object;
const paint = kind === "ColorSpace" ? {target: settingsValue("PaintTarget"), mode: settingsValue("PaintMode"), value: settingsValue("PaintValue")} : {};
if (await commitSettings("ofdgoApplyStyleResource", kind, clear ? "" : settings.resource.id, settings.page, object, JSON.stringify(paint))) await resourceTask(loadManagedResources);
}
function renderLayerSettings() {
const entries = settings.data.page.layers || [], content = settingsElements.Content;
settingsPages(settingsElements.Context, "Page", settings.page, value => { settings.page = Number(value); settings.selected = -1; loadDocumentSettings(); });
if (settings.selected >= entries.length) settings.selected = -1;
if (!state.editing && settings.selected < 0 && entries.length) settings.selected = 0;
const types = {Background: "背景", Body: "正文", Foreground: "前景"};
const options = entries.map((entry, index) => [index, `${entry.ID} · ${types[entry.Type] || entry.Type} · ${entry.ObjectCount}个图元`]);
if (state.editing) options.unshift([-1, "新增"]);
settingsSelect(settingsElements.Context, "图层", "Entry", options, settings.selected, value => { settings.selected = Number(value); renderDocumentSettings(); });
const entry = entries[settings.selected];
if (!entry && !state.editing) { settingsElements.Status.textContent = "暂无图层"; return; }
settingsSelect(content, "类型", "LayerType", Object.entries(types), entry?.Type || "Body");
settingsSelect(content, "样式", "DrawParam", [["", "无"], ...settings.data.resources.filter(item => item.kind === "DrawParam").map(item => [item.id, item.name])], referenceIDKey(entry?.DrawParam));
content.querySelectorAll("select").forEach(field => { field.disabled = !state.editing; });
if (!state.editing) return;
const write = async (operation, order = 0, objects = []) => {
const selected = settings.selected;
if (!entry && operation === "add") settings.selected = entries.length;
if (!await commitSettings("ofdgoChangeLayer", operation, settings.page, entry?.ID || "", settingsValue("LayerType"), settingsValue("DrawParam"), order, objects)) settings.selected = selected;
};
settingsButton(entry ? "修改" : "添加", () => write(entry ? "update" : "add"), false, "Save");
settingsButton("删除", () => write("delete"), !entry || entry.ObjectCount > 0, "Delete");
settingsButton("上移", () => write("move", entry.Order - 1), !entry || entry.Order === 0, "Up");
settingsButton("下移", () => write("move", entry.Order + 1), !entry || entry.Order >= entries.filter(item => item.Type === entry.Type).length - 1, "Down");
if (entry) {
const objects = settingsSelect(content, "图元", "LayerObjects", (settings.data.objects || []).map(item => [item.id, `${item.id} · ${item.text || "图元"}`]), null);
objects.multiple = true;
objects.size = Math.min(4, Math.max(2, objects.options.length));
for (const option of objects.options) option.selected = canvasEditor.items().some(item => item.index === settings.page && item.id === option.value && !item.scoped);
settingsButton("移入", () => write("objects", 0, [...objects.selectedOptions].map(option => option.value)), !objects.options.length, "MoveObjects");
}
}
function renderTemplateSettings() {
const entries = settings.data.page.templates || [], bindings = settings.data.page.bindings || [], content = settingsElements.Content;
settingsPages(settingsElements.Context, "Page", settings.page, value => { settings.page = Number(value); loadDocumentSettings(); });
if (settings.selected >= entries.length) settings.selected = -1;
if (!state.editing && settings.selected < 0 && entries.length) settings.selected = 0;
const options = entries.map((entry, index) => [index, `${entry.Name || entry.ID}${bindings.some(binding => referenceIDKey(binding.TemplateID) === referenceIDKey(entry.ID)) ? " · 已绑定" : ""}`]);
if (state.editing) options.unshift([-1, "新增"]);
settingsSelect(settingsElements.Context, "模板", "Entry", options, settings.selected, value => { settings.selected = Number(value); renderDocumentSettings(); });
const entry = entries[settings.selected];
if (!entry && !state.editing) { settingsElements.Status.textContent = "暂无模板"; return; }
settingsField(content, "名称", "Name", entry?.Name);
settingsSelect(content, "层次", "TemplateOrder", [["Background", "背景"], ["Foreground", "前景"]], entry?.ZOrder || "Background");
if (entry) appendInfoLine(content, "文件", entry.BaseLoc);
content.querySelectorAll("input,select").forEach(field => { field.disabled = !state.editing; });
const bound = entry ? bindings.findIndex(binding => referenceIDKey(binding.TemplateID) === referenceIDKey(entry.ID)) : -1;
if (entry && bound >= 0) {
const order = bindings[bound].ZOrder;
if (!state.editing) appendInfoLine(content, "绑定层次", {Background: "背景", Foreground: "前景"}[order] || "默认");
}
if (!state.editing) return;
settingsPages(content, "SourcePage", settings.page);
document.querySelector("#settingsSourcePage").closest("label").firstElementChild.textContent = "来源页";
const write = async (operation, order = 0) => {
if (operation === "replace" && !window.confirm("替换模板内容?当前文档中引用此模板的页面将同步更新。")) return;
const selected = settings.selected;
if (operation === "add") settings.selected = entries.length;
if (!await commitSettings("ofdgoChangeTemplate", operation, ["add", "replace"].includes(operation) ? Number(settingsValue("SourcePage")) : settings.page, entry?.ID || "", settingsValue("Name"), ["bind", "order"].includes(operation) ? settingsValue("BindingOrder") : settingsValue("TemplateOrder"), order)) settings.selected = selected;
};
settingsButton(entry ? "修改" : "添加", () => write(entry ? "update" : "add"), false, "Save");
settingsButton("替换", () => write("replace"), !entry, "Replace");
settingsButton("删除", () => write("delete"), !entry || bound >= 0, "Delete");
if (entry) {
settingsSelect(content, "绑定层次", "BindingOrder", [["", "默认"], ["Background", "背景"], ["Foreground", "前景"]], bound < 0 ? "" : bindings[bound].ZOrder || "");
settingsButton(bound < 0 ? "绑定" : "应用", () => write(bound < 0 ? "bind" : "order"), false, "Bind");
settingsButton("解绑", () => write("unbind"), bound < 0, "Unbind");
settingsButton("上移", () => write("move", bound - 1), bound <= 0, "Up");
settingsButton("下移", () => write("move", bound + 1), bound < 0 || bound >= bindings.length - 1, "Down");
}
}
function renderBookmarkSettings() {
const entries = settings.data.bookmarks || [];
if (settings.selected >= entries.length) settings.selected = -1;
if (!state.editing && settings.selected < 0 && entries.length) settings.selected = 0;
const options = entries.map((entry, index) => [index, entry.Name]);
if (state.editing) options.unshift([-1, "新增"]);
settingsSelect(settingsElements.Context, "书签", "Entry", options, settings.selected, value => { settings.selected = Number(value); renderDocumentSettings(); });
const entry = entries[settings.selected];
if (!entry && !state.editing) { settingsElements.Status.textContent = "暂无书签"; return; }
settingsField(settingsElements.Content, "名称", "Name", entry?.Name).required = true;
renderDestinationFields(settingsElements.Content, entry?.Dest);
settingsElements.Content.querySelectorAll("input,select").forEach(field => { field.disabled = !state.editing; });
updateDestinationFields();
settingsButton("定位", () => {
settingsElements.Panel.close();
const d = entry.Dest;
navigateDestination({type: d.Type, pageID: d.PageID, left: d.Left, top: d.Top, right: d.Right, bottom: d.Bottom, zoom: d.Zoom, omitLeft: d.OmitLeft, omitTop: d.OmitTop, omitZoom: d.OmitZoom}, false);
}, !entry, "Locate");
if (!state.editing) return;
settingsButton(entry ? "修改" : "添加", async () => {
if (!settingsElements.Form.reportValidity()) return;
const bookmark = {Name: settingsValue("Name"), Dest: destinationFromSettings()};
const selected = settings.selected;
if (!entry) settings.selected = entries.length;
if (!await commitSettings("ofdgoChangeBookmark", entry ? "update" : "add", entry?.Name || "", JSON.stringify(bookmark))) settings.selected = selected;
}, false, "Save");
settingsButton("删除", () => commitSettings("ofdgoChangeBookmark", "delete", entry.Name, "{}"), !entry, "Delete");
}
function renderExtensionSettings() {
const entries = settings.data.extensions || [];
if (!entries.length) settingsElements.Status.textContent = "暂无扩展";
for (const entry of entries) {
const section = document.createElement("div");
section.className = "settings-detail";
for (const [key, label] of [["AppName", "应用"], ["Company", "单位"], ["AppVersion", "版本"], ["Date", "日期"], ["RefID", "引用"]]) appendInfoLine(section, label, entry[key]);
for (const property of entry.Property || []) appendInfoLine(section, property.Name, property.Value);
for (const value of entry.ExtendData || []) appendInfoLine(section, "文件", value);
for (const value of entry.Data || []) {
for (const attr of value.Attr || []) appendInfoLine(section, attr.Name.Local, attr.Value);
const text = document.createElement("pre");
text.textContent = value.Content;
section.append(text);
}
settingsElements.Content.append(section);
}
}
async function openResourceManagement() {
if (!state.doc || !state.ready || document.body.hasAttribute("aria-busy")) return;
if (resourceManager.pick) { finishResourcePick(); return; }
resourceElements.Panel.showModal();
resourceElements.Page.replaceChildren(...state.doc.pages.map((page, index) => new Option(`第${index + 1}页`, String(index))));
resourceElements.Page.value = String(state.pageIndex);
await resourceTask(loadManagedResources);
}
async function resourceTask(action) {
if (resourceManager.busy) return;
resourceManager.busy = true;
resourceElements.Form.inert = true;
resourceElements.Status.textContent = "处理中";
resourceElements.Panel.setAttribute("aria-busy", "true");
try {
await action();
if (resourceElements.Status.textContent === "处理中") resourceElements.Status.textContent = "";
} catch (err) {
resourceElements.Status.textContent = resourceError(err.message);
} finally {
resourceManager.busy = false;
resourceElements.Form.inert = false;
resourceElements.Panel.removeAttribute("aria-busy");
updateResourceControls();
}
}
function resourceError(message) {
if (/still referenced/.test(message)) return "资源仍被引用";
if (/unsupported content/.test(message)) return "引用无法确认";
if (/empty media|nonempty Audio/.test(message)) return "文件内容为空";
if (/image mask .*dimensions differ from source/.test(message)) return "蒙版尺寸不符";
if (/image mask contains non-binary pixel/.test(message)) return "蒙版像素不符";
return message;
}
async function loadManagedResources() {
const sequence = state.openSeq;
const items = await callWASM("ofdgoResources");
if (sequence !== state.openSeq || !resourceElements.Panel.open) return;
resourceManager.items = items || [];
await renderResourceList();
}
async function renderResourceList() {
const items = resourceManager.items.filter(item => item.kind === resourceElements.Kind.value);
const selected = items.find(item => item.id === resourceManager.selected?.id && item.kind === resourceManager.selected?.kind) || items[0];
resourceElements.List.replaceChildren();
resourceElements.Empty.hidden = items.length !== 0;
for (const item of items) {
const button = document.createElement("button");
button.type = "button";
button.className = "resource-option";
button.setAttribute("role", "option");
button.setAttribute("aria-selected", String(item === selected));
button.title = item.file || item.name;
for (const value of [item.name, item.missing ? "缺失" : item.file ? formatBytes(item.size) : item.kind === "Font" ? "外部" : "-", item.complete ? String(item.references?.length || 0) : "未知"]) {
const span = document.createElement("span");
span.textContent = value;
button.append(span);
}
button.addEventListener("click", () => resourceTask(() => selectManagedResource(item)));
button.addEventListener("keydown", event => {
const index = items.indexOf(item);
const target = { ArrowDown: Math.min(items.length - 1, index + 1), ArrowUp: Math.max(0, index - 1), Home: 0, End: items.length - 1 }[event.key];
if (target == null) return;
event.preventDefault();
resourceTask(() => selectManagedResource(items[target])).then(() => {
if (resourceElements.Panel.open && resourceManager.selected === items[target]) resourceElements.List.children[target]?.focus();
});
});
resourceElements.List.append(button);
}
await selectManagedResource(selected);
}
async function selectManagedResource(item) {
const same = item?.id === resourceManager.selected?.id && item?.kind === resourceManager.selected?.kind;
const reference = same ? resourceElements.Reference.value : "0";
releaseResourcePreview();
resourceManager.selected = item || null;
const items = resourceManager.items.filter(entry => entry.kind === resourceElements.Kind.value);
Array.from(resourceElements.List.children).forEach((node, i) => {
node.setAttribute("aria-selected", String(items[i] === item));
node.tabIndex = items[i] === item ? 0 : -1;
});
const references = item?.references || [];
const roles = {Font: "字体", ResourceID: "资源", Substitution: "替换", ImageMask: "蒙版", Thumbnail: "缩略", AttachID: "附件"};
resourceElements.Reference.replaceChildren(...references.map((ref, index) => new Option(`${ref.page >= 0 ? `第${ref.page + 1}页` : ref.file}${ref.object ? ` · ${ref.object}` : ""} · ${roles[ref.role] || ref.role}`, String(index))));
if (!references.length) resourceElements.Reference.add(new Option(!item || item.complete ? "暂无引用" : "引用未知", ""));
else resourceElements.Reference.value = references[Number(reference)] ? String(Number(reference)) : "0";
updateResourceControls();
if (same) await loadResourceTargets();
else await syncResourceReference();
}
function updateResourceControls() {
const item = resourceManager.selected, editing = state.editing;
const disabled = resourceManager.busy || !state.doc || document.body.hasAttribute("aria-busy");
const actionable = item && ["Audio", "Video", "Attachment"].includes(item.kind);
resourceElements.Add.hidden = resourceElements.Replace.hidden = resourceElements.Delete.hidden = !editing;
resourceElements.Rename.hidden = !editing || item?.kind !== "Attachment";
resourceElements.Rename.disabled = disabled || item?.kind !== "Attachment";
resourceElements.Add.disabled = disabled;
resourceElements.Reference.disabled = disabled || !item?.references?.length;
const style = item && ["ColorSpace", "DrawParam"].includes(item.kind);
resourceElements.Preview.disabled = disabled || (!style && (!item?.file || item.missing || item.kind === "Attachment"));
resourceElements.Preview.textContent = style ? "查看" : "预览";
resourceElements.Export.disabled = disabled || !item?.file || item.missing;
resourceElements.Replace.disabled = disabled || !item || item.kind !== "Attachment" && !item.complete;
resourceElements.Replace.textContent = style ? "修改" : item?.kind === "Font" ? item.file ? "外置" : "嵌入" : "替换";
resourceElements.Delete.disabled = disabled || !item || item.kind !== "Attachment" && (!item.complete || Boolean(item.references?.length));
resourceElements.Delete.title = item?.kind === "Attachment" ? "删除附件" : item?.references?.length ? "资源仍被引用" : "删除资源";
resourceElements.Locate.disabled = disabled || !item?.references?.length;
resourceElements.Binding.hidden = !editing || !actionable;
const actions = resourceElements.Binding.hidden ? resourceElements.Actions : resourceElements.BindingActions;
if (resourceElements.Close.parentElement !== actions) actions.append(resourceElements.Close);
const target = resourceManager.targets.find(target => target.id === resourceElements.Object.value);
resourceElements.Pick.disabled = disabled || !editing || !actionable || Boolean(state.composite);
resourceElements.Bind.disabled = disabled || !actionable || !target || target.bound;
resourceElements.Unbind.disabled = disabled || !actionable || !target?.bound;
}
async function syncResourceReference() {
const reference = resourceManager.selected?.references?.[Number(resourceElements.Reference.value)];
if (reference?.page >= 0) resourceElements.Page.value = String(reference.page);
await loadResourceTargets(reference?.page >= 0 ? reference.object : "");
updateResourceControls();
}
async function loadResourceTargets(preferred) {
const resource = resourceManager.selected;
if (preferred === undefined) {
const reference = resource?.references?.[Number(resourceElements.Reference.value)];
preferred = resourceElements.Object.value || (reference?.page === Number(resourceElements.Page.value) ? reference.object : "");
}
resourceManager.targets = [];
resourceElements.Object.replaceChildren(new Option("选择图元", ""));
if (!state.editing || !resource || !["Audio", "Video", "Attachment"].includes(resource.kind)) return;
const page = Number(resourceElements.Page.value), sequence = state.openSeq;
const items = await callWASM("ofdgoResourceTargets", page, resource.kind, resource.id);
if (!resourceElements.Panel.open || sequence !== state.openSeq || resource !== resourceManager.selected || page !== Number(resourceElements.Page.value)) return;
resourceManager.targets = items || [];
const types = {TextObject: "文字", PathObject: "图形", ImageObject: "图片", CompositeObject: "组合", CompositeGraphicUnit: "组合"};
for (const item of resourceManager.targets) resourceElements.Object.add(new Option(`${item.id} · ${(item.text || types[item.type] || "图元").slice(0, 50)}${item.bound ? " · 已绑定" : ""}`, item.id));
const selected = canvasEditor.selected;
if (!preferred && selected?.index === page && !selected.scoped) preferred = selected.id;
resourceElements.Object.value = resourceManager.targets.some(item => item.id === preferred) ? preferred : "";
}
async function locateManagedResource() {
const item = resourceManager.selected, sequence = state.openSeq;
if (!item?.references?.length) return;
const locations = await callWASM("ofdgoResourceLocations", item.kind, item.id, Number(resourceElements.Reference.value));
if (sequence !== state.openSeq || !resourceElements.Panel.open || item !== resourceManager.selected) return;
if (!locations?.length) { resourceElements.Status.textContent = "暂无可见位置"; return; }
const focus = async location => {
if (sequence !== state.openSeq) return;
resourceElements.Panel.close();
const box = location.bounds;
await focusPageRegion({page: location.page, x: box.X, y: box.Y, width: box.W, height: box.H}, "资源", "center");
if (sequence !== state.openSeq || !state.editing) return;
const target = canvasEditor.pageItems(location.page - 1).find(item => item.id === location.object && !item.scoped);
if (target) canvasEditor.select(target);
};
if (locations.length === 1) await focus(locations[0]);
else resourceChoices(locations, location => `第${location.page}页 · ${location.object}`, location => resourceTask(() => focus(location)));
}
function resourceChoices(items, label, choose) {
el.objectPickerList.replaceChildren();
for (const item of items) {
const button = document.createElement("button");
button.type = "button";
button.className = "button";
button.textContent = label(item);
button.addEventListener("click", () => { el.objectPicker.close(); choose(item); });
el.objectPickerList.append(button);
}
el.objectPicker.showModal();
}
async function beginResourcePick() {
if (!state.editing || state.composite || !["Audio", "Video", "Attachment"].includes(resourceManager.selected?.kind)) return;
const sequence = state.openSeq;
if (!await renderPage(Number(resourceElements.Page.value), {fit: false})) return;
if (sequence !== state.openSeq || !resourceElements.Panel.open) return;
resourceManager.pick = {sequence, status: el.statusText.textContent};
resourceElements.Panel.close();
canvasEditor.enabled = false;
el.viewerPanel.classList.add("resource-picking");
setBusy(false);
setStatus("选取图元");
el.viewerPanel.focus({preventScroll: true});
}
function finishResourcePick(reopen = true) {
const picking = resourceManager.pick;
if (!picking) return;
resourceManager.pick = null;
el.viewerPanel.classList.remove("resource-picking");
if (picking.sequence === state.openSeq) {
updateEditorTools();
setBusy(document.body.hasAttribute("aria-busy"));
setStatus(picking.status);
if (reopen && !resourceElements.Panel.open) {
resourceElements.Panel.showModal();
resourceElements.Pick.focus();
}
}
}
function pickResourceObject(event) {
const picking = resourceManager.pick;
if (!picking || event.button !== 0) return;
event.preventDefault();
event.stopImmediatePropagation();
if (resourceManager.busy || document.body.hasAttribute("aria-busy")) return;
const items = canvasEditor.itemsAt(event).filter(item => !item.scoped && !item.draft);
if (!items.length) { setStatus("未选中图元"); return; }
resourceTask(async () => {
const resource = resourceManager.selected, page = items[0].index;
const targets = await callWASM("ofdgoResourceTargets", page, resource.kind, resource.id);
if (resourceManager.pick !== picking || picking.sequence !== state.openSeq) return;
const available = items.filter(item => targets?.some(target => target.id === item.id));
if (!available.length) { setStatus("图元不可绑定"); return; }
const choose = async item => {
if (resourceManager.pick !== picking || picking.sequence !== state.openSeq) return;
finishResourcePick();
resourceElements.Page.value = String(item.index);
await loadResourceTargets(item.id);
resourceElements.Status.textContent = "已选取";
};
if (available.length === 1) await choose(available[0]);
else resourceChoices(available, item => `${item.id} · ${(item.text || "图元").slice(0, 50)}`, item => resourceTask(() => choose(item)));
});
}
function chooseResourceFile(action) {
const item = action === "add" ? {kind: resourceElements.Kind.value, id: ""} : resourceManager.selected;
if (!item) return;
resourceManager.fileAction = {action, item, sequence: state.openSeq};
resourceElements.File.value = "";
resourceElements.File.accept = {Audio: "audio/*", Video: "video/*", Image: ".png,.jpg,.jpeg,.bmp,.tif,.tiff,.jb2,.jbig2"}[item.kind] || "";
resourceElements.File.click();
}
async function changeManagedResource(action, item, file) {
if (!item || !state.editing) return;
releaseResourcePreview();
const format = file?.name.includes(".") ? file.name.split(".").at(-1).toUpperCase() : "";
const changed = item.kind === "Attachment" && action !== "bind" && action !== "unbind"
? await changeDocument("ofdgoChangeAttachment", null, action, item.id, file ? [file.name] : [], file ? [file] : [])
: await changeDocument("ofdgoChangeResource", null, action, item.kind, item.id, format, file || null, Number(resourceElements.Page.value), resourceElements.Object.value);
if (changed) {
await loadManagedResources();
if (action === "bind" || action === "unbind") resourceElements.Status.textContent = action === "bind" ? "已绑定" : "已解绑";
}
}
function releaseResourcePreview() {
resourceManager.sequence++;
for (const player of resourceElements.Viewer.querySelectorAll("audio, video")) { player.pause(); player.removeAttribute("src"); player.load(); }
resourceElements.Viewer.replaceChildren();
resourceElements.Viewer.hidden = true;
if (resourceManager.url) URL.revokeObjectURL(resourceManager.url);
if (resourceManager.font) document.fonts.delete(resourceManager.font);
resourceManager.url = resourceManager.font = null;
}
async function previewManagedResource() {
if (["ColorSpace", "DrawParam"].includes(resourceManager.selected?.kind)) {
await openResourceSettings(resourceManager.selected.kind, resourceManager.selected.id);
return;
}
const item = resourceManager.selected;
if (!item?.file) return;
if (state.doc.encryption?.encrypted && !window.confirm("资源将按明文读取,是否继续?")) return;
releaseResourcePreview();
const sequence = resourceManager.sequence, openSeq = state.openSeq;
let result;
try {
result = await callWASM("ofdgoExportResource", JSON.stringify([item.kind, item.id]), null);
} finally {
state.exportRequestID = 0;
el.cancelExportButton.hidden = true;
}
if (sequence !== resourceManager.sequence || openSeq !== state.openSeq || !resourceElements.Panel.open) return;
const format = item.format.toLowerCase();
const mime = {mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", m4a: "audio/mp4", mp4: "video/mp4", webm: "video/webm", png: "image/png", jpeg: "image/jpeg", jpg: "image/jpeg", bmp: "image/bmp"}[format] || "application/octet-stream";
resourceManager.url = URL.createObjectURL(new Blob([result.blob], {type: mime}));
let element;
if (item.kind === "Font") {
const font = new FontFace("OFDResourcePreview", `url("${resourceManager.url}")`);
await font.load();
if (sequence !== resourceManager.sequence) return;
document.fonts.add(font);
resourceManager.font = font;
element = document.createElement("span");
element.style.fontFamily = '"OFDResourcePreview"';
element.style.fontSize = "24px";
element.textContent = "Aa 0123 字体预览";
} else {
element = document.createElement(item.kind === "Image" ? "img" : item.kind === "Audio" ? "audio" : "video");
element.src = resourceManager.url;
if (item.kind === "Image") element.alt = item.name;
else { element.controls = true; element.preload = "metadata"; }
element.addEventListener("error", () => { resourceElements.Status.textContent = "浏览器无法预览此格式"; });
}
resourceElements.Viewer.append(element);
resourceElements.Viewer.hidden = false;
}
function resourceFileName(item) {
let name = item.kind === "Font" ? item.file.split("/").at(-1) : item.name;
if (!name.includes(".") && item.format) name += "." + item.format.toLowerCase();
return name;
}
function downloadAttachment(attachment) {
return downloadResource(attachment, "ofdgoExportAttachment", "附件");
}
el.attachmentCancel.addEventListener("click", () => el.attachmentDialog.close());
el.attachmentDialog.addEventListener("cancel", event => { if (el.attachmentForm.inert) event.preventDefault(); });
el.attachmentDialog.addEventListener("close", () => {
el.attachmentForm.reset();
delete el.attachmentDialog.attachmentEdit;
});
el.attachmentForm.addEventListener("submit", async event => {
event.preventDefault();
const edit = el.attachmentDialog.attachmentEdit;
if (!edit || edit.openSeq !== state.openSeq || !el.attachmentForm.reportValidity()) return;
const files = Array.from(el.attachmentFile.files);
const names = edit.action === "rename" ? [el.attachmentName.value] : files.map(file => file.name);
if (await changeDocument("ofdgoChangeAttachment", null, edit.action, edit.id, names, files)) {
el.attachmentDialog.close();
setStatus(edit.action === "rename" ? "附件已改名" : "附件已添加");
}
});
function openAttachmentDialog(action, attachment) {
if (!state.editing || !state.ready || state.exporting || document.body.hasAttribute("aria-busy")) return;
const rename = action === "rename";
el.attachmentForm.reset();
el.attachmentDialog.attachmentEdit = {action, id: attachment?.id || "", openSeq: state.openSeq};
el.attachmentTitle.textContent = rename ? "附件改名" : "添加附件";
el.attachmentSubmit.textContent = rename ? "确定" : "添加";
el.attachmentFileRow.hidden = rename;
el.attachmentFile.required = !rename;
el.attachmentNameRow.hidden = !rename;
el.attachmentName.required = rename;
el.attachmentName.value = attachment?.name || "";
el.attachmentStatus.textContent = "";
el.attachmentDialog.showModal();
(rename ? el.attachmentName : el.attachmentFile).focus();
}
async function saveDocumentImage(resource) {
if (!resource || !state.doc || document.body.hasAttribute("aria-busy")) return;
const seq = state.openSeq;
try {
const image = await callWASM("ofdgoImages", resource);
if (seq !== state.openSeq) return;
await downloadResource({id: image.id, fileName: image.name}, "ofdgoExportImages", "图片");
} catch (err) {
if (seq === state.openSeq) showError(err, false);
}
}
el.saveImageButton.addEventListener("click", () => saveDocumentImage(canvasEditor.selected?.imageResource));
async function downloadResource(attachment, api, label) {
if (!state.doc || document.body.hasAttribute("aria-busy")) {
return;
}
if (state.doc.encryption?.encrypted && !window.confirm(`${label}将按明文下载,是否继续?`)) return;
const openSeq = state.openSeq;
state.exporting = true;
updateControls();
setBusy(true, `正在读取${label}`, null, `正在读取${label}`);
try {
const file = window.showSaveFilePicker ? await window.showSaveFilePicker({ suggestedName: attachment.fileName }) : null;
if (openSeq !== state.openSeq) {
return;
}
const result = await callWASM(api, attachment.id, file);
if (openSeq !== state.openSeq) {
return;
}
if (result.blob) {
downloadBytes(result.blob, result.mime, attachment.fileName);
}
setStatus(`${label}已下载(${formatBytes(result.size)})`);
} catch (err) {
if (openSeq === state.openSeq) {
if (err.name === "AbortError") {
setStatus("下载已取消");
} else {
showError(err, false);
}
}
} finally {
state.exporting = false;
state.exportRequestID = 0;
el.cancelExportButton.hidden = true;
if (openSeq === state.openSeq) {
setBusy(false);
updateControls();
}
}
}
function encryptionMode() {
const info = state.doc?.encryption;
if (!info?.encrypted) return "none";
if (info.layers !== 1 || info.policyRequired) return "keep";
return info.method === "1.1.1" ? "password" : info.method === "1.1.2" ? "certificate" : "keep";
}
function openEncryptionPanel() {
if (!state.doc || document.body.hasAttribute("aria-busy")) return;
clearEncryptionForm();
el.encryptionType.value = encryptionMode();
el.encryptionKeep.hidden = el.encryptionType.value !== "keep";
updateEncryptionType();
el.encryptionPanel.showModal();
}
function updateEncryptionType() {
const mode = el.encryptionType.value;
const existing = mode !== "none" && mode === encryptionMode() && !state.encryptionChanged;
const password = mode === "password" && !existing;
const certificates = mode === "certificate" && !existing;
el.encryptionCurrentRow.hidden = !existing;
el.encryptionCurrent.textContent = mode === "password" ? state.encryptionDirty ? "待保存" : "已加密" : (state.doc?.encryption?.users || []).join("、") || "已加密";
el.encryptionChange.hidden = mode === "keep";
el.encryptionUserRow.hidden = el.encryptionPasswordRow.hidden = el.encryptionConfirmRow.hidden = !password;
el.encryptionCertificatesRow.hidden = !certificates;
el.encryptionPassword.required = el.encryptionConfirm.required = password;
el.encryptionCertificates.required = certificates;
el.encryptionUser.value = mode === "password" && encryptionMode() === mode ? state.doc.encryption.users?.[0] || "" : "";
el.encryptionPassword.value = el.encryptionConfirm.value = "";
el.encryptionCertificates.value = "";
el.encryptionStatus.textContent = mode === "keep" && state.doc?.encryption?.policyRequired ? "请重新选择加密方式" : "";
el.encryptionSubmit.disabled = mode === "keep" && Boolean(state.doc?.encryption?.policyRequired);
el.encryptionSubmit.textContent = mode === encryptionMode() && !state.encryptionChanged ? "确定"
: mode === "none" ? "解密" : mode !== encryptionMode() ? "加密" : mode === "password" ? "改密" : "修改";
}
async function applyEncryption(event) {
event.preventDefault();
if (!state.doc || document.body.hasAttribute("aria-busy") || el.encryptionSubmit.disabled) return;
const mode = el.encryptionType.value;
if (mode === encryptionMode() && !state.encryptionChanged) {
el.encryptionPanel.close();
return;
}
if (mode === "password" && !el.encryptionPassword.value) {
el.encryptionStatus.textContent = "请输入口令";
el.encryptionPassword.focus();
return;
}
if (mode === "password" && el.encryptionPassword.value !== el.encryptionConfirm.value) {
el.encryptionStatus.textContent = "口令不一致";
el.encryptionConfirm.focus();
return;
}
if (mode === "certificate" && !el.encryptionCertificates.files?.length) {
el.encryptionStatus.textContent = "请选择接收者证书";
return;
}
const openSeq = state.openSeq;
const status = mode === "none" ? "保存后将解密" : mode !== encryptionMode() ? "保存后将加密"
: mode === "password" ? "保存后将改密" : "保存后将生效";
const options = mode === "none" ? null : { password: new TextEncoder().encode(el.encryptionPassword.value), userName: el.encryptionUser.value.trim() };
el.encryptionForm.inert = true;
setBusy(true, "正在处理文档");
try {
if (mode === "certificate") options.recipients = await encryptionRecipients(Array.from(el.encryptionCertificates.files));
if (openSeq !== state.openSeq) return;
const doc = await callWASM("ofdgoSetEncryption", options);
if (openSeq !== state.openSeq) return;
if (!state.editorInfo) state.savedRevision = doc.revision;
state.encryptionDirty = true;
setEditorInfo(doc);
state.doc.encryption = doc.encryption;
state.ofdBytes = null;
el.encryptionPanel.close();
renderSecurity();
setStatus(status);
} catch (err) {
if (openSeq === state.openSeq) el.encryptionStatus.textContent = err.message;
} finally {
options?.password.fill(0);
if (openSeq === state.openSeq) {
el.encryptionPassword.value = el.encryptionConfirm.value = "";
el.encryptionForm.inert = false;
setBusy(false);
}
}
}
async function encryptionRecipients(files) {
const names = new Set();
return Promise.all(files.map(async file => {
const base = file.name.replace(/\.[^.]+$/, "") || "用户";
let name = base, index = 2;
while (names.has(name)) name = `${base} ${index++}`;
names.add(name);
return {userName:name, certificate:new Uint8Array(await file.arrayBuffer())};
}));
}
function clearEncryptionForm() {
el.encryptionForm.inert = false;
el.encryptionPassword.value = "";
el.encryptionConfirm.value = "";
el.encryptionUser.value = "";
el.encryptionCertificates.value = "";
state.encryptionChanged = false;
}
function renderSecurity() {
const encryption = state.doc?.encryption;
el.securityPanel.hidden = !state.doc;
el.securityEncryption.textContent = state.encryptionDirty ? "待保存" : encryption?.encrypted ? "已加密" : "未加密";
const scheme = { "1.1.1": "口令", "1.1.2": "证书" }[encryption?.method];
el.securityScheme.textContent = encryption?.layers > 1 ? "多重" : scheme || encryption?.method || "";
el.securitySchemeRow.hidden = !encryption?.encrypted;
el.securityMethod.textContent = scheme ? "SM4-CBC" : "";
el.securityMethodRow.hidden = !encryption?.encrypted || !scheme;
el.encryptSaveButton.disabled = !state.doc || !state.ready || state.exporting || document.body.hasAttribute("aria-busy");
el.verifyButton.disabled = el.encryptSaveButton.disabled;
el.signButton.disabled = el.encryptSaveButton.disabled;
}
function cancelSigning() {
if (el.signCancel.disabled) return;
state.signCanceled = true;
el.signStatus.textContent = "正在取消操作";
cancelExport();
}
function finishSealPosition(returnToForm = true) {
const preview = sealPreview;
if (!preview) return;
sealPreview = null;
if (preview.pointer != null && el.viewerPanel.hasPointerCapture(preview.pointer)) el.viewerPanel.releasePointerCapture(preview.pointer);
URL.revokeObjectURL(preview.url);
sealPosition.Layer.remove();
sealPosition.Image.removeAttribute("src");
el.viewerPanel.classList.remove("seal-picking");
canvasEditor.enabled = preview.enabled;
if (preview.sidebar && COMPACT_LAYOUT.matches) {
Object.assign(state, preview.sidebar);
updateSidebarState();
}
setBusy(document.body.hasAttribute("aria-busy"));
if (returnToForm && preview.sequence === state.openSeq && !el.signPanel.open) el.signPanel.showModal();
if (preview.sequence === state.openSeq) setStatus(preview.status);
}
async function openSealPosition() {
const file = el.signSeal.files?.[0];
if (!file || !state.doc || document.body.hasAttribute("aria-busy")) return;
const sequence = state.openSeq;
let preview;
setBusy(true);
try {
const range = el.signPages.value.trim(), mode = el.signPlacement.value;
const selected = await callWASM("ofdgoParsePageRange", range);
if (mode === "seam" && selected.length < 2) throw new Error("骑缝签章至少选择两页");
const result = await callWASM("ofdgoPreviewSignatureSeal", new Uint8Array(await file.arrayBuffer()), `1-${state.doc.pageCount}`);
if (sequence !== state.openSeq || !el.signPanel.open || file !== el.signSeal.files?.[0]) return;
sealPreview = {pages:result.pages, selected, range, mode, ratio:document.querySelector("#signKeepRatio").checked ? result.width / result.height : 0, sequence, enabled:canvasEditor.enabled, status:el.statusText.textContent, url:URL.createObjectURL(new Blob([result.svg], {type:"image/svg+xml"}))};
preview = sealPreview;
sealPosition.Image.src = sealPreview.url;
await sealPosition.Image.decode();
if (sealPreview !== preview) return;
if (sequence !== state.openSeq || !el.signPanel.open) { finishSealPosition(false); return; }
canvasEditor.enabled = false;
if (COMPACT_LAYOUT.matches) {
sealPreview.sidebar = {showPages:state.showPages, showMeta:state.showMeta};
state.showPages = state.showMeta = false;
updateSidebarState();
}
el.viewerPanel.classList.add("seal-picking");
el.signPanel.close();
el.signStatus.textContent = "";
setStatus("拖动选取签章区域,Esc 取消");
el.viewerPanel.focus({preventScroll:true});
} catch (error) {
if (sequence === state.openSeq && (!preview || sealPreview === preview)) {
finishSealPosition();
el.signStatus.textContent = error.message;
}
}
finally { if (sequence === state.openSeq) setBusy(false); }
}
function sealPointer(event) {
const preview = sealPreview;
const point = pagePoint(event.clientX, event.clientY, preview.surface.getBoundingClientRect(), preview.page, state.rotation);
return {x:preview.page.x + point.x, y:preview.page.y + point.y};
}
function beginSealDrag(event) {
const preview = sealPreview;
if (!preview || preview.confirming || event.button !== 0) return;
const surface = event.target.closest(".page-surface");
if (!surface) return;
event.preventDefault();
event.stopImmediatePropagation();
const index = Number(surface.closest(".page-shell").dataset.pageIndex);
if ((preview.mode === "seam" || preview.selected.length > 1) && !preview.selected.includes(index)) {
setStatus("请在所选页码范围内定位");
return;
}
preview.page = preview.pages[index];
preview.surface = surface;
preview.start = sealPointer(event);
preview.pointer = event.pointerId;
preview.box = null;
sealPosition.Stamp.hidden = true;
surface.append(sealPosition.Layer);
el.viewerPanel.setPointerCapture(event.pointerId);
}
function moveSealDrag(event) {
const preview = sealPreview;
if (!preview?.start || preview.pointer !== event.pointerId) return;
event.preventDefault();
event.stopImmediatePropagation();
const point = sealPointer(event), start = preview.start, page = preview.page;
const dx = point.x - start.x, dy = point.y - start.y;
let width = Math.abs(dx), height = Math.abs(dy);
const count = preview.mode === "seam" ? preview.selected.length : 1;
if (preview.ratio) {
width = Math.min(width, height * preview.ratio / count);
height = width * count / preview.ratio;
}
const x = dx < 0 ? start.x - width : start.x, y = dy < 0 ? start.y - height : start.y;
preview.box = {x:preview.mode === "seam" ? page.x + page.width - x - width : x, y, width:width * count, height};
sealPosition.Stamp.hidden = false;
Object.assign(sealPosition.Stamp.style, {left:`${(x-page.x)/page.width*100}%`, top:`${(y-page.y)/page.height*100}%`, width:`${width/page.width*100}%`, height:`${height/page.height*100}%`});
Object.assign(sealPosition.Image.style, {width:`${count*100}%`, left:`${preview.mode === "seam" ? -preview.selected.indexOf(page.index)*100 : 0}%`});
}
async function confirmSealPosition(event) {
const preview = sealPreview;
if (!preview?.start || preview.pointer !== event.pointerId || preview.confirming) return;
moveSealDrag(event);
preview.start = null;
el.viewerPanel.releasePointerCapture(event.pointerId);
const box = preview.box;
if (!box || box.width < 0.01 || box.height < 0.01) { sealPosition.Layer.remove(); return; }
preview.confirming = true;
const range = preview.mode === "normal" && preview.selected.length === 1 ? String(preview.page.index + 1) : preview.range;
try {
await callWASM("ofdgoSignaturePlacement", {...box, pages:range, placement:preview.mode});
if (sealPreview !== preview || preview.sequence !== state.openSeq) return;
el.signPages.value = range;
for (const [name, key] of [["X", "x"], ["Y", "y"], ["Width", "width"], ["Height", "height"]]) el[`sign${name}`].value = String(box[key]);
finishSealPosition();
} catch (error) {
if (sealPreview === preview) { finishSealPosition(); el.signStatus.textContent = error.message; }
}
}
function updateSignPlacement() {
const stamp = Boolean(el.signSeal.files?.length);
el.signPlacementFields.hidden = !stamp;
el.signPlacement.disabled = !stamp;
for (const input of [el.signPages, el.signX, el.signY, el.signWidth, el.signHeight]) {
input.disabled = !stamp;
input.required = stamp;
}
document.querySelector('label[for="signX"]').textContent = el.signPlacement.value === "seam" ? "右侧内缩" : "横坐标";
el.signX.setAttribute("aria-label", el.signPlacement.value === "seam" ? "右侧内缩(毫米)" : "横坐标(毫米)");
}
async function signDocument(event) {
event.preventDefault();
if (!state.doc || document.body.hasAttribute("aria-busy")) return;
if (!el.signCertificate.files?.length || !el.signKey.files?.length || !el.signRoots.files?.length) {
el.signStatus.textContent = "请选择证书、私钥和信任根";
return;
}
if (el.signMode.value === "replace" && !window.confirm("替换现有签名,是否继续?")) return;
let openSeq = state.openSeq;
let key;
const keyPassword = new TextEncoder().encode(el.signKeyPassword.value);
el.signKeyPassword.value = "";
state.exporting = true;
state.signing = true;
state.signCanceled = false;
el.signCancel.disabled = false;
setBusy(true, "正在签署文档", null, "正在签署文档");
el.signForm.querySelectorAll("input, select, button[type=submit]").forEach(input => { input.disabled = true; });
try {
const name = `${baseFileName()}_签章.ofd`;
const file = window.showSaveFilePicker ? await window.showSaveFilePicker({suggestedName:name, types:[{description:"OFD",accept:{"application/ofd":[".ofd"]}}]}) : null;
if (openSeq !== state.openSeq || state.signCanceled) return;
if (canvasEditor.input || canvasEditor.crop || canvasEditor.nudge || canvasEditor.nudgeCommit) {
setBusy(false);
if (!await canvasEditor.commitNudge() || !await canvasEditor.commitText() || !await canvasEditor.commitCrop()) return;
openSeq = state.openSeq;
setBusy(true, "正在签署文档", null, "正在签署文档");
}
key = (await securityFiles(el.signKey))[0];
const options = {
key, keyPassword, certificate:(await securityFiles(el.signCertificate))[0], roots:await securityFiles(el.signRoots),
intermediates:await securityFiles(el.signIntermediates), seal:(await securityFiles(el.signSeal))[0] || null,
pages:el.signPages.value.trim(), placement:el.signPlacement.value,
x:Number(el.signX.value), y:Number(el.signY.value), width:Number(el.signWidth.value), height:Number(el.signHeight.value),
mode:el.signMode.value, lock:el.signLock.checked,
compression: compressionOptions(el.outputCompression.value),
};
el.signKey.value = "";
if (openSeq !== state.openSeq || state.signCanceled || !el.signPanel.open) return;
const result = await callWASM("ofdgoSaveSigned", null, options, file);
if (openSeq !== state.openSeq) return;
if (result.blob) downloadBytes(result.blob, result.mime, name);
el.signPanel.close();
setStatus("签署已另存");
} catch (err) {
if (openSeq === state.openSeq) el.signStatus.textContent = err.name === "AbortError" ? "签署已取消" : err.message;
} finally {
key?.fill(0);
keyPassword.fill(0);
el.signKey.value = "";
el.signForm.querySelectorAll("input, select, button[type=submit]").forEach(input => { input.disabled = false; });
el.signCancel.disabled = false;
if (state.signCanceled && openSeq === state.openSeq) el.signStatus.textContent = "签署已取消";
state.signing = false;
updateSignPlacement();
state.exporting = false;
state.exportRequestID = 0;
el.cancelExportButton.hidden = true;
if (openSeq === state.openSeq) { setBusy(false); updateControls(); }
}
}
async function securityFiles(input) {
return Promise.all(Array.from(input.files || [], async file => new Uint8Array(await file.arrayBuffer())));
}
async function verifyDocument(event) {
event.preventDefault();
if (!state.doc || document.body.hasAttribute("aria-busy")) return;
const openSeq = state.openSeq;
setBusy(true, "正在验证签名", null, "正在验证签名");
el.verifyForm.inert = true;
try {
const options = {
roots: await securityFiles(el.verifyRoots), certificates: await securityFiles(el.verifyCerts),
timestampRoots: await securityFiles(el.verifyTimeRoots), tokens: await securityFiles(el.verifyTokens),
crls: await securityFiles(el.verifyCRLs), ocsp: await securityFiles(el.verifyOCSP),
requireTimestamp: el.verifyRequireTime.checked, requireRevocation: el.verifyRequireRevocation.checked,
};
if (openSeq !== state.openSeq || !el.verifyPanel.open) return;
const info = await callWASM("ofdgoVerifySignatures", options);
if (openSeq !== state.openSeq || !el.verifyPanel.open) return;
Object.assign(state.doc, info);
renderMeta();
el.verifyPanel.close();
setStatus(info.signatureCount ? "验签完成" : "无签名");
} catch (err) {
if (openSeq === state.openSeq) el.verifyStatus.textContent = err.message;
} finally {
el.verifyForm.inert = false;
if (openSeq === state.openSeq) setBusy(false);
}
}
function openExportPanel() {
if (!state.doc || document.body.hasAttribute("aria-busy")) {
return;
}
el.exportForm.reset();
if (state.editing && state.pageSelection.size) {
el.exportSpecified.checked = true;
el.exportRange.value = selectedPageRange();
}
el.exportPanel.showModal();
updateExportRange();
}
async function updateExportRange() {
const specified = el.exportSpecified.checked;
const value = el.exportRange.value.trim();
const openSeq = state.openSeq;
state.exportPages = null;
el.exportRangeRow.hidden = !specified;
el.exportRange.removeAttribute("aria-invalid");
el.exportSubmit.disabled = specified;
el.exportRangeStatus.textContent = specified && value ? "正在校验页码" : "";
if (!specified || !value) {
return;
}
const current = () => openSeq === state.openSeq && el.exportPanel.open
&& el.exportSpecified.checked && value === el.exportRange.value.trim();
try {
const indices = await callWASM("ofdgoParsePageRange", value);
if (current()) {
state.exportPages = indices;
el.exportSubmit.disabled = false;
el.exportRangeStatus.textContent = `共${indices.length}页`;
}
} catch {
if (current()) {
el.exportRange.setAttribute("aria-invalid", "true");
el.exportRangeStatus.textContent = "页码无效";
}
}
}
function addBatchFiles(files) {
let rejected = false;
for (const file of files) {
if (!/\.(ofd|pdf)$/i.test(file.name)) { rejected = true; continue; }
batch.items.push({ file, format: "", pages: "", status: "pending", text: "待转", details: [] });
}
if (rejected) batchElements.Status.textContent = "仅支持PDF、OFD";
renderBatchList();
}
function renderBatchList() {
const fragment = document.createDocumentFragment();
for (const item of batch.items) {
const row = document.createElement("tr");
const name = row.insertCell();
const label = document.createElement("span");
label.className = "convert-name";
label.textContent = item.file.name;
label.title = item.file.name;
name.append(label);
let detailRow;
if (item.details.length) {
detailRow = document.createElement("tr");
detailRow.className = "convert-details";
const cell = detailRow.insertCell();
cell.colSpan = 5;
const details = document.createElement("details");
const summary = document.createElement("summary");
summary.textContent = "提示";
const messages = document.createElement("ul");
messages.className = "convert-messages";
messages.tabIndex = 0;
messages.setAttribute("aria-label", `${item.file.name}的转换提示`);
for (const message of item.details) {
const entry = document.createElement("li");
entry.textContent = message;
messages.append(entry);
}
details.append(summary, messages);
cell.append(details);
}
const reset = () => {
item.details = [];
detailRow?.remove();
batchStatus(item, "pending", "待转");
batchElements.Status.textContent = "";
batchElements.Progress.value = 0;
updateBatchControls();
};
const pagesCell = row.insertCell();
pagesCell.dataset.label = "页码";
const pages = document.createElement("input");
pages.type = "text";
pages.placeholder = "全部";
pages.title = "如1-3,5;留空为全部";
pages.setAttribute("aria-label", `${item.file.name}的输出页码`);
pages.value = item.pages;
pages.disabled = batch.running;
pages.addEventListener("change", () => { item.pages = pages.value; reset(); });
pagesCell.append(pages);
const formatCell = row.insertCell();
formatCell.dataset.label = "格式";
const formatControl = document.createElement("span");
formatControl.className = "convert-format";
const format = document.createElement("select");
format.setAttribute("aria-label", `${item.file.name}的输出格式`);
format.append(new Option("默认", ""), ...batch.formats.map(value => new Option(value.label, value.value)));
format.value = item.format;
format.disabled = batch.running;
format.addEventListener("change", () => { item.format = format.value; reset(); });
formatControl.append(format);
formatCell.append(formatControl);
item.cell = row.insertCell();
item.cell.textContent = item.text;
item.cell.dataset.status = item.status;
item.cell.dataset.label = "状态";
const action = row.insertCell();
const remove = document.createElement("button");
remove.type = "button";
remove.className = "icon-button";
remove.textContent = "×";
remove.title = "移除";
remove.setAttribute("aria-label", `移除${item.file.name}`);
remove.disabled = batch.running;
remove.addEventListener("click", () => { batch.items.splice(batch.items.indexOf(item), 1); renderBatchList(); });
action.append(remove);
fragment.append(row);
if (detailRow) fragment.append(detailRow);
}
batchElements.List.replaceChildren(fragment);
batchElements.Empty.hidden = batch.items.length > 0;
updateBatchControls();
}
function updateBatchControls() {
for (const name of ["Add", "Clear", "Format", "Compression", "FontMode", "Destination", "DPI"]) batchElements[name].disabled = batch.running;
batchElements.FontRow.hidden = !(batch.items.length ? batch.items : [null]).some(item => (item?.format || batchElements.Format.value) === "ofd");
batchElements.Compression.disabled ||= (batch.items.length ? batch.items : [null]).every(item => (item?.format || batchElements.Format.value) === "txt");
batchElements.Clear.disabled ||= !batch.items.length;
batchElements.Start.disabled = batch.running || !batch.items.length || !batch.formats.length;
batchElements.Start.hidden = batch.running;
batchElements.Cancel.hidden = !batch.running;
batchElements.Cancel.disabled = batch.canceled;
batchElements.Close.textContent = batch.running ? "收起" : "关闭";
batchElements.Count.textContent = batch.items.length ? `${batch.items.length}个文件` : "";
batchElements.DPIRow.hidden = !(batch.items.length ? batch.items : [null]).some(batchUsesDPI);
batchElements.Button.toggleAttribute("data-running", batch.running);
}
function batchUsesDPI(item) {
const format = item?.format || batchElements.Format.value;
if (batch.formats.find(value => value.value === format)?.raster) return true;
return Boolean(item && /\.pdf$/i.test(item.file.name) && format !== "txt"
&& (format !== "pdf" || item.pages.trim()));
}
function batchStatus(item, status, text) {
item.status = status;
item.text = text;
item.cell.textContent = text;
item.cell.dataset.status = status;
}
function batchProgress(item, position, count, progress) {
if (batch.canceled) return;
const now = performance.now();
const previous = batch.progress;
const sameStage = previous?.item === item && previous?.progress.phase === progress.phase;
if (sameStage && now - batch.progressTime < 80 && progress.completed !== progress.total) return;
batch.progressTime = now;
batch.progress = { item, position, count, progress, started: sameStage ? previous.started : now };
updateBatchProgress();
}
function updateBatchProgress() {
if (!batch.progress) return;
const { item, position, count, progress, started } = batch.progress;
const compressing = progress.phase === "compress" || progress.phase === "write.compress";
const fontMode = (item?.format || batchElements.Format.value) === "ofd" ? batchElements.FontMode.value : "0";
const stage = compressing ? "压缩" : progress.phase?.startsWith("write.") ? "写入" : { open: "读取", pages: "解析", convert: "转换", prepare: "准备", fonts: { 1: "嵌入", 2: "外置" }[fontMode] || "整理", "fonts.prepare": "准备", "fonts.scan": "检查", "fonts.commit": "整理", resources: "资源", references: "检查", write: "写入", export: "导出", pack: "打包", commit: "保存" }[progress.phase] || "处理";
const detail = progress.total > 0 ? `${progress.completed}/${progress.total}` : (compressing || progress.phase === "fonts") ? `已处理 ${progress.completed || 0} 项` : "";
const seconds = Math.floor((performance.now() - started) / 1000);
if (item) batchStatus(item, "running", stage);
batchElements.Status.textContent = `${progress.phase === "pack" ? "" : `文件 ${position + 1}/${count} · `}正在${stage}${detail ? ` · ${detail}` : ""}${seconds ? ` · ${seconds}秒` : ""}`;
batchElements.Progress.setAttribute("aria-label", "阶段进度");
batchElements.Progress.max = progress.total || 1;
if (progress.total > 0) batchElements.Progress.value = Math.min(progress.completed, progress.total);
else batchElements.Progress.removeAttribute("value");
}
function batchCall(name, args, onProgress) {
return new Promise((resolve, reject) => {
if (batch.workerError) { reject(batch.workerError); return; }
const id = ++batch.sequence;
batch.activeID = id;
batch.requests.set(id, { resolve, reject, onProgress, destination: name === "ofdgoConvertFile" ? args[3] : null });
try { batch.worker.postMessage({ id, name, args }); }
catch (err) { batch.requests.delete(id); batch.activeID = 0; reject(err); }
});
}
function startBatchWorker() {
batch.workerError = null;
return new Promise((resolve, reject) => {
const worker = new Worker("./ofdgo_wasm.js");
batch.worker = worker;
const fail = async error => {
batch.workerError = error;
worker.terminate();
reject(error);
const requests = [...batch.requests.values()];
batch.requests.clear();
for (const request of requests) {
if (request.destination && request.outputName) {
try { await request.destination.removeEntry(request.outputName, { recursive: true }); }
catch (err) { error = new Error(`${error.message};清理失败:${err.message}`); }
}
request.reject(error);
}
};
worker.onerror = event => fail(new Error(event.message || "转换引擎异常"));
worker.onmessageerror = () => fail(new Error("转换数据传递失败"));
worker.onmessage = async ({ data }) => {
if (data.type === "ready") { resolve(); return; }
if (data.type === "exit") { fail(new Error(data.error)); return; }
if (data.type === "progress") {
if (!batch.canceled) batchElements.Status.textContent = { download: "正在下载", compile: "正在编译", start: "正在启动" }[data.phase] || "正在准备";
return;
}
const request = batch.requests.get(data.id);
if (!request) return;
if (data.type === "font") {
try {
const font = batch.localFonts.get(data.name);
if (!font) throw new Error("系统字体不可用");
if (!batch.fontData.has(font)) batch.fontData.set(font, fontManager.read(font));
const bytes = await batch.fontData.get(font);
worker.postMessage({ type: "font", request: data.request, bytes });
} catch (err) {
worker.postMessage({ type: "font", request: data.request, error: err.message });
}
return;
}
if (data.type === "output") { request.outputName = data.name; return; }
if (data.type === "conversion") { request.onProgress?.(data); return; }
batch.requests.delete(data.id);
if (batch.activeID === data.id) batch.activeID = 0;
if (data.ok) request.resolve(data.data);
else request.reject(Object.assign(new Error(data.error), { code: data.code, name: data.canceled ? "AbortError" : "Error" }));
};
});
}
function cancelBatch() {
batch.canceled = true;
batchElements.Cancel.disabled = true;
batch.progress = null;
batchElements.Status.textContent = "正在取消";
if (batch.activeID) batch.worker.postMessage({ type: "cancel", id: batch.activeID });
}
function warnBatch(event) {
if (batch.running) { event.preventDefault(); event.returnValue = ""; }
}
async function runBatch() {
if (batch.running || !batch.items.length) return;
const defaultFormat = batch.formats.find(format => format.value === batchElements.Format.value);
if (!defaultFormat) return;
const pageRange = item => item.pages.trim() === "全部" ? "" : item.pages.trim();
const dpi = Number(batchElements.DPI.value);
const compression = compressionOptions(batchElements.Compression.value);
const archive = batchElements.Destination.value === "archive";
const backend = state.renderBackend;
const fontKey = [fontManager.permission, fontManager.records().map(font => [font.id, font.name, font.enabled, font.checksum])];
for (const item of batch.items) {
const format = batch.formats.find(format => format.value === (item.format || defaultFormat.value));
const key = JSON.stringify([format.value, pageRange(item), batchUsesDPI(item) ? dpi : 0, archive, backend, fontKey, compression, format.value === "ofd" ? Number(batchElements.FontMode.value) : 0]);
if (item.settingsKey !== key) {
item.status = "pending";
item.text = "待转";
item.details = [];
item.settingsKey = key;
}
}
renderBatchList();
let jobs = batch.items.filter(item => item.status !== "done" && item.status !== "unchanged");
if (!jobs.length) {
jobs = [...batch.items];
for (const item of jobs) { item.status = "pending"; item.text = "待转"; item.details = []; }
}
batch.running = true;
batch.canceled = false;
batch.progress = null;
const progressTimer = window.setInterval(updateBatchProgress, 1000);
renderBatchList();
window.addEventListener("beforeunload", warnBatch);
let destination = null;
let zipFile = null;
let storage = null;
let stagingName = "";
let packed = false;
const entries = [];
try {
if (archive) {
if (window.showSaveFilePicker) zipFile = await window.showSaveFilePicker({ suggestedName: "转换结果.zip", types: [{ description: "ZIP", accept: { "application/zip": [".zip"] } }] });
if (navigator.storage?.getDirectory) {
storage = await navigator.storage.getDirectory();
stagingName = `ofdgo-convert-${crypto.randomUUID()}`;
destination = await storage.getDirectoryHandle(stagingName, { create: true });
}
} else destination = await window.showDirectoryPicker({ mode: "readwrite" });
if (batch.canceled) return;
if (jobs.some(item => /\.pdf$/i.test(item.file.name) || !["ofd", "txt"].includes(item.format || defaultFormat.value)
|| (item.format || defaultFormat.value) === "ofd" && batchElements.FontMode.value === "1")) {
await requestLocalFontsBeforeOpen();
}
if (batch.canceled) return;
batch.localFonts = fontManager.localEntries();
batch.fontData = new Map();
batchElements.Status.textContent = "正在准备";
await startBatchWorker();
const fonts = await fontManager.files(fontManager.records());
const names = new Set();
for (const [position, item] of jobs.entries()) {
if (batch.canceled) break;
item.details = [];
const format = batch.formats.find(format => format.value === (item.format || defaultFormat.value));
const base = item.file.name.replace(/\.[^.]+$/, "").replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").replace(/[. ]+$/g, "") || "document";
let unique = base;
const outputName = () => (format.paged ? unique : `${unique}.${format.extension}`).toLowerCase();
for (let suffix = 2; archive && names.has(outputName()); suffix++) unique = `${base} (${suffix})`;
names.add(outputName());
const options = { format: format.value, pages: pageRange(item), dpi, backend, archive, compression, base: unique, credentials: null,
fontNames: [...batch.localFonts.keys()], fontMode: format.value === "ofd" ? Number(batchElements.FontMode.value) : 0 };
try {
for (;;) {
try {
const result = await batchCall("ofdgoConvertFile", [item.file, options, fonts, destination], progress => batchProgress(item, position, jobs.length, progress));
for (const file of result.files) entries.push(file);
item.details = (result.pdf?.Warnings || []).map(warning => `${warning.Page ? `第${warning.Page}页:` : ""}${warning.Message}`);
for (const font of result.fonts || []) item.details.push(`${font.name}:${font.changed ? options.fontMode === 1 ? "已嵌入" : "已外置" : `未处理 · ${fontBindingError(font.reason)}`}`);
if (result.unchanged && !result.bytes) {
batchStatus(item, "unchanged", "原样");
item.details = ["无需转换,源文件未改动"];
} else batchStatus(item, archive ? "ready" : "done", archive ? "待存" : "完成");
break;
} catch (err) {
if (batch.canceled || !["credentialsRequired", "invalidCredentials", "pdfPassword"].includes(err.code)) throw err;
const code = err.code === "pdfPassword" ? options.credentials ? "invalidCredentials" : "credentialsRequired" : err.code;
options.credentials?.password?.fill(0);
options.credentials?.key?.fill(0);
options.credentials?.keyPassword?.fill(0);
options.credentials = await requestCredentials(code, state.openSeq, err.code === "pdfPassword");
if (!options.credentials) throw new DOMException("已取消解锁", "AbortError");
const preservesEncryption = err.code === "pdfPassword" && format.value === "pdf" && compression.mode !== 0 && !options.pages;
if (!preservesEncryption && !window.confirm("转换结果将不保留原加密,是否继续?")) throw new DOMException("已取消转换", "AbortError");
}
}
} catch (err) {
batchStatus(item, err.name === "AbortError" ? "canceled" : "failed", err.name === "AbortError" ? "取消" : "失败");
item.details = [err.message];
if (batch.workerError) throw err;
} finally {
batch.progress = null;
options.credentials?.password?.fill(0);
options.credentials?.key?.fill(0);
options.credentials?.keyPassword?.fill(0);
}
batchElements.Progress.max = jobs.length;
batchElements.Progress.setAttribute("aria-label", "转换进度");
batchElements.Progress.value = position + 1;
}
if (archive && entries.length && !batch.canceled) {
const result = await batchCall("ofdgoPackFiles", [entries, zipFile, compression], progress => batchProgress(null, 0, 1, progress));
if (result.blob) downloadBytes(result.blob, "application/zip", "转换结果.zip");
packed = true;
for (const item of jobs) if (item.status === "ready") batchStatus(item, "done", "完成");
}
const done = jobs.filter(item => item.status === "done").length;
const failed = jobs.filter(item => item.status === "failed").length;
const unchanged = batch.items.filter(item => item.status === "unchanged").length;
batchElements.Status.textContent = `${batch.canceled ? "取消 · " : ""}完成 ${done}${failed ? ` · 失败 ${failed}` : ""}${unchanged ? ` · 原样 ${unchanged}` : ""}`;
} catch (err) {
batchElements.Status.textContent = err.name === "AbortError" ? "转换取消" : err.message;
} finally {
window.clearInterval(progressTimer);
batch.progress = null;
if (!batchElements.Progress.hasAttribute("value")) batchElements.Progress.value = 0;
if (archive && !packed) for (const item of jobs) if (item.status === "ready") { item.status = "pending"; item.text = "待转"; }
batch.worker?.terminate();
batch.worker = null;
batch.requests.clear();
batch.activeID = 0;
batch.fontData?.clear();
batch.localFonts?.clear();
if (storage && stagingName) {
try { await storage.removeEntry(stagingName, { recursive: true }); }
catch { batchElements.Status.textContent += " · 临时文件清理失败"; }
}
batch.running = false;
window.removeEventListener("beforeunload", warnBatch);
renderBatchList();
}
}
function compressionOptions(value) {
const preset = Number(value) || 0;
return preset > 2 ? { mode: 2, level: preset - 2 } : { mode: preset };
}
async function exportFile(whole, indices = null, value = el.exportFormat.value) {
if (!state.doc || document.body.hasAttribute("aria-busy")) {
return;
}
const saving = value === "ofd";
const compression = compressionOptions(el.outputCompression.value);
const outputOptions = compression.mode ? [compression] : [];
if (saving && !state.editorInfo && !state.ofdBytes) {
return;
}
if (!saving && state.doc.encryption?.encrypted && !window.confirm("此格式将输出明文,是否继续?")) return;
const format = saving ? { value: "ofd", label: "OFD", extension: "ofd", mime: "application/ofd" } : exportFormatInfo(value);
if (!format) {
return;
}
const archive = whole && !saving && format.value !== "pdf" && format.value !== "txt";
const label = archive ? "ZIP" : format.label;
const extension = archive ? "zip" : format.extension;
const mime = archive ? "application/zip" : format.mime;
let openSeq = state.openSeq;
const fileName = saving && indices !== null ? `${baseFileName()}_选页.ofd` : whole ? `${baseFileName()}.${extension}` : pageFileName(extension);
const pageIndex = state.pageIndex;
const dpi = exportFormatUsesDPI(format.value) ? currentImageDPI() : 0;
const status = saving ? "正在保存文档" : whole ? STATUS.exporting : STATUS.pageExporting;
state.exporting = true;
updateControls();
setBusy(true, "正在生成文件", null, status);
try {
const file = window.showSaveFilePicker ? await window.showSaveFilePicker({
suggestedName: fileName,
types: [{ description: label, accept: { [mime]: [`.${extension}`] } }],
}) : null;
if (openSeq !== state.openSeq) {
return;
}
if (canvasEditor.input || canvasEditor.crop || canvasEditor.nudge || canvasEditor.nudgeCommit) {
setBusy(false);
if (!await canvasEditor.commitNudge()) return;
if (!await canvasEditor.commitText()) return;
if (!await canvasEditor.commitCrop()) return;
openSeq = state.openSeq;
setBusy(true, "正在生成文件", null, status);
}
const readingSave = saving && !state.editorInfo && indices === null && compression.mode === 0;
const result = readingSave
? { label: "OFD", size: state.ofdBytes.byteLength, mime, blob: state.ofdBytes }
: saving ? await callWASM("ofdgoSaveDocument", indices, ...outputOptions, file) : whole
? await callWASM("ofdgoExportDocument", format.value, dpi, indices, state.renderBackend, ...outputOptions, file)
: await callWASM("ofdgoExportPage", pageIndex, format.value, dpi, state.renderBackend, ...outputOptions, file);
if (openSeq !== state.openSeq) {
return;
}
if (readingSave && file) {
const writer = await file.createWritable();
await writer.write(result.blob);
await writer.close();
delete result.blob;
}
if (result.blob) {
downloadBytes(result.blob, result.mime, fileName);
}
if (saving && indices === null && state.editorInfo) {
state.savedRevision = state.editorInfo.revision;
state.packageDirty = false;
state.encryptionDirty = false;
setDirty(false);
}
setStatus(`${result.label}已${saving ? "保存" : "导出"}(${formatBytes(result.size)})`);
return true;
} catch (err) {
if (openSeq === state.openSeq) {
const canceled = saving ? "保存已取消" : "导出已取消";
if (el.batchPagesPanel.open) el.batchPagesStatus.textContent = err.name === "AbortError" ? canceled : err.message;
if (err.name === "AbortError") {
setStatus(canceled);
} else {
showError(err, false);
}
}
} finally {
state.exporting = false;
state.exportRequestID = 0;
el.cancelExportButton.hidden = true;
if (openSeq === state.openSeq) {
setBusy(false);
updateControls();
}
}
}
function cancelExport() {
if (remoteDownload) {
remoteDownload.abort();
return;
}
if (!state.exportRequestID || el.cancelExportButton.disabled) {
return;
}
el.cancelExportButton.disabled = true;
if (state.imageImporting) {
el.createCancel.disabled = true;
el.createStatus.textContent = "正在取消操作";
} else if (state.importing) {
el.importCancel.disabled = true;
el.importStatus.textContent = "正在取消操作";
}
setProgress("正在取消操作", null);
wasmWorker.postMessage({ type: "cancel", id: state.exportRequestID });
}
function virtualIndex(offsets, top) {
let low = 0, high = offsets.length - 1;
while (low + 1 < high) {
const mid = (low + high) >>> 1;
if (offsets[mid] <= top) low = mid;
else high = mid;
}
return low;
}
function createPageWindow(host, viewport, overscan, create, release, observe) {
host.classList.add("virtual-pages");
return { host, viewport, overscan, create, release, observe, nodes: new Map(), offsets: new Float64Array(1), origin: 0, total: 0, gap: 0 };
}
function windowInset(view) {
return view.host.getBoundingClientRect().top - view.viewport.getBoundingClientRect().top
+ view.viewport.scrollTop - view.viewport.clientTop;
}
function pageWindowTop(view) {
const inset = windowInset(view);
if (view.total <= SCROLL_SEGMENT_SIZE) return view.viewport.scrollTop - inset;
const delta = view.viewport.scrollTop - view.scrollTop;
if (!delta) return view.logicalTop;
if (view.relativeScroll) return view.logicalTop + delta;
const max = view.viewport.scrollHeight - view.viewport.clientHeight;
return view.viewport.scrollTop / Math.max(1, max) * (max + view.total - SCROLL_SEGMENT_SIZE) - inset;
}
function scrollPageWindowBy(view, delta) {
if (view) syncPageWindow(view, pageWindowTop(view) + delta);
}
function scrollViewerBy(delta) {
if (state.pageWindow) scrollPageWindowBy(state.pageWindow, delta);
else el.viewerPanel.scrollTop += delta;
}
function syncPageWindow(view, target = null) {
if (!view || view.offsets.length < 2) return;
const { host, viewport, offsets, nodes } = view;
const extent = Math.min(SCROLL_SEGMENT_SIZE, view.total);
host.style.height = `${extent}px`;
const inset = windowInset(view), height = viewport.clientHeight;
const maxTop = Math.max(0, viewport.scrollHeight - height);
const logicalMax = maxTop + view.total - extent;
const logical = Math.max(-inset, Math.min(logicalMax - inset, target ?? pageWindowTop(view)));
viewport.scrollTop = logicalMax > 0 ? (logical + inset) * maxTop / logicalMax : 0;
view.scrollTop = viewport.scrollTop;
view.logicalTop = logical;
view.origin = logical - viewport.scrollTop + inset;
const first = virtualIndex(offsets, Math.max(view.origin, logical - view.overscan));
const last = virtualIndex(offsets, Math.min(view.origin + extent, logical + height + view.overscan));
const selection = host === el.svgHost && state.documentSelection ? document.getSelection() : null;
const selected = selection?.rangeCount && isDocumentSelection(selection.getRangeAt(0));
let changed = false;
for (const [index, node] of nodes) {
if ((index < first || index > last) && view.release(node, index)) {
node.remove();
nodes.delete(index);
changed = true;
}
}
for (let index = first; index <= last; index++) {
if (nodes.has(index)) continue;
const node = view.create(state.doc.pages[index]);
nodes.set(index, node);
const next = [...host.children].find(child => Number(child.dataset.pageIndex) > index);
host.insertBefore(node, next || null);
changed = true;
node.style.top = `${offsets[index] - view.origin}px`;
view.observe(node, index);
}
for (const [index, node] of nodes) {
const top = offsets[index] - view.origin;
const itemHeight = offsets[index + 1] - offsets[index] - view.gap;
node.style.top = `${Math.max(-itemHeight, Math.min(extent, top))}px`;
node.style.visibility = top + itemHeight < 0 || top >= extent ? "hidden" : "";
}
if (selected && changed) selection.getRangeAt(0).selectNodeContents(host);
view.anchor = pageWindowAnchor(view);
}
function pageWindowAnchor(view) {
const top = pageWindowTop(view);
const index = virtualIndex(view.offsets, top);
return { index, offset: top - view.offsets[index] };
}
function releaseFlowShell(shell, index) {
if (shell.contains(document.activeElement) || canvasEditor.drag?.item?.index === index) return false;
unmountPage(index);
if (state.selectedPages.has(index)) return false;
state.pageObserver?.unobserve(shell);
state.visiblePages.delete(index);
return true;
}
function createFlowShell(page) {
const shell = document.createElement("div");
shell.className = "page-shell";
shell.dataset.pageIndex = String(page.index);
const surface = document.createElement("div");
surface.className = "page-surface";
const placeholder = document.createElement("div");
placeholder.className = "page-placeholder";
placeholder.textContent = `第 ${page.index + 1} 页`;
shell.append(surface, placeholder);
layoutPageShell(shell, page);
return shell;
}
function renderPageFlow() {
el.svgHost.replaceChildren();
state.pageWindow = null;
state.visiblePages.clear();
el.viewerPanel.classList.remove("single-page-fits-height");
if (!state.doc) {
return;
}
if (state.pageObserver) {
state.pageObserver.disconnect();
}
el.emptyState.hidden = true;
el.pageFrame.hidden = false;
state.pageWindow = createPageWindow(el.svgHost, el.viewerPanel, 600, createFlowShell, releaseFlowShell, observeFlowPage);
layoutPages();
}
function resetPageFlow(keepCache = false) {
if (!keepCache) {
state.pageCache.clear();
releasePageResources();
}
state.selectedPages.clear();
state.documentSelection = null;
state.visiblePages.clear();
resetPageLoading();
}
function resetPageLoading() {
releaseMediaPlayers();
for (const task of state.pageInFlight.values()) {
task.resolve(null);
}
state.pageRenderQueue = [];
state.pageInFlight.clear();
resetThumbnails();
if (state.pageObserver) {
state.pageObserver.disconnect();
state.pageObserver = null;
}
}
function observeFlowPage(shell) {
if (!shell) return;
const observer = flowPageObserver();
if (observer) {
observer.observe(shell);
} else {
const index = Number(shell.dataset.pageIndex);
state.visiblePages.add(index);
renderFlowPage(index, { priority: 4 });
}
}
function flowPageObserver() {
if (!("IntersectionObserver" in window)) {
return null;
}
if (!state.pageObserver) {
const openSeq = state.openSeq;
state.pageObserver = new IntersectionObserver((entries) => {
if (openSeq !== state.openSeq) {
return;
}
for (const entry of entries) {
const index = Number.parseInt(entry.target.dataset.pageIndex, 10);
if (state.pageWindow && state.pageWindow.nodes.get(index) !== entry.target) continue;
if (!entry.isIntersecting) {
state.visiblePages.delete(index);
unmountPage(index);
continue;
}
state.visiblePages.add(index);
state.selectedPages.delete(index);
renderFlowPage(index, { openSeq, priority: 4 });
}
trimPageCache();
}, {
root: el.viewerPanel,
rootMargin: "600px 0px",
});
}
return state.pageObserver;
}
function unmountPage(index) {
const shell = pageShell(index);
if (!shell) return;
const selection = document.getSelection();
const range = !selection.isCollapsed && selection.rangeCount ? selection.getRangeAt(0) : null;
if (canvasEditor.input?.item.index === index || canvasEditor.crop?.item.index === index
|| range && (!state.documentSelection || !isDocumentSelection(range)) && range.intersectsNode(shell)) {
state.selectedPages.add(index);
return;
}
releaseMediaPlayers(shell.querySelector(".page-surface"));
shell.querySelector(".page-surface").replaceChildren();
shell.classList.remove("rendered");
state.selectedPages.delete(index);
}
async function renderFlowPage(index, options = {}) {
const openSeq = options.openSeq || state.openSeq;
if (openSeq !== state.openSeq) {
return null;
}
try {
const page = await loadPageData(index, { openSeq, priority: options.priority ?? 2,
refresh: state.editing && state.pageCache.get(index)?.previewOnly });
if (openSeq !== state.openSeq) {
return null;
}
if (page) {
const shell = pageShell(index);
if (shell && !shell.classList.contains("rendered") && (!state.pageObserver || state.visiblePages.has(index) || index === state.pageIndex)) {
mountPageSVG(index, page, openSeq);
}
updateThumbnail(index, openSeq);
}
trimPageCache();
return page;
} catch (err) {
if (openSeq === state.openSeq) {
markFlowPageError(index, err);
}
if (options.throwError && openSeq === state.openSeq) {
throw err;
}
return null;
}
}
function loadPageData(index, options = {}) {
const openSeq = options.openSeq || state.openSeq;
if (openSeq !== state.openSeq) {
return Promise.resolve(null);
}
const key = `${openSeq}:${index}`;
const priority = options.priority ?? 3;
const current = state.pageInFlight.get(key);
if (current) {
current.priority = Math.min(current.priority, priority);
return current.promise;
}
if (!options.refresh && state.pageCache.has(index)) {
const page = state.pageCache.get(index);
state.pageCache.delete(index);
state.pageCache.set(index, page);
return Promise.resolve(page);
}
let resolve;
let reject;
const promise = new Promise((done, fail) => {
resolve = done;
reject = fail;
});
const task = { key, index, openSeq, priority, refresh: options.refresh, resolve, reject, promise };
state.pageInFlight.set(key, task);
state.pageRenderQueue.push(task);
schedulePageRender();
return promise;
}
function schedulePageRender() {
if (state.pageRenderRunning) {
return;
}
state.pageRenderRunning = true;
requestAnimationFrame(processPageRenderQueue);
}
async function processPageRenderQueue() {
try {
await nextFrame();
while (state.pageRenderQueue.length) {
let next = 0;
for (let index = 1; index < state.pageRenderQueue.length; index++) {
if (comparePageRenderTask(state.pageRenderQueue[index], state.pageRenderQueue[next]) < 0) next = index;
}
const [task] = state.pageRenderQueue.splice(next, 1);
try {
if (task.openSeq !== state.openSeq) {
task.resolve(null);
continue;
}
if (!task.refresh && state.pageCache.has(task.index)) {
task.resolve(state.pageCache.get(task.index));
continue;
}
const page = await callWASM("ofdgoRenderPage", task.index, state.renderBackend, state.renderDPI, displayMode() === "raster");
if (state.pageInFlight.get(task.key) !== task) {
task.resolve(null);
continue;
}
const scope = state.composite;
if (scope?.index === task.index) {
const objects = await callWASM("ofdgoCompositeObjects", task.index, scope.key);
if (scope === state.composite && task.openSeq === state.openSeq) scope.objects = objects;
}
await loadSVGFonts(page.fonts, task.openSeq);
if (task.openSeq === state.openSeq && state.pageInFlight.get(task.key) === task) {
for (const image of page.images) {
if (!state.svgImages.has(image.name)) {
const blob = new Blob([image.bytes], { type: image.mime });
state.svgImages.set(image.name, { url: URL.createObjectURL(blob), size: blob.size });
}
}
page.imageNames = page.images.map((image) => image.name);
delete page.images;
if (task.refresh && state.editing) {
await Promise.all(page.imageNames.map(name => {
const image = new Image();
image.src = state.svgImages.get(name).url;
return image.decode();
}));
}
if (task.openSeq !== state.openSeq || state.pageInFlight.get(task.key) !== task) {
task.resolve(null);
continue;
}
page.cacheBytes = (page.svg.length + page.text.length + page.annotations.length) * 2;
page.text = JSON.parse(page.text);
page.annotations = JSON.parse(page.annotations);
state.pageCache.set(task.index, page);
}
task.resolve(page);
} catch (err) {
task.reject(err);
} finally {
if (state.pageInFlight.get(task.key) === task) state.pageInFlight.delete(task.key);
}
}
} finally {
state.pageRenderRunning = false;
if (!state.pageInFlight.size) releasePageResources();
}
}
function trimPageCache() {
const protectedPages = new Set([state.pageIndex, ...state.visiblePages, ...state.visibleThumbnails, ...state.selectedPages]);
for (const view of [state.pageWindow, state.thumbnailWindow]) {
for (const index of view?.nodes.keys() || []) protectedPages.add(index);
}
for (const task of state.pageInFlight.values()) {
protectedPages.add(task.index);
}
const references = new Map();
let bytes = 0;
for (const page of state.pageCache.values()) {
bytes += page.cacheBytes;
for (const name of page.imageNames) {
references.set(name, (references.get(name) || 0) + 1);
}
}
for (const image of state.svgImages.values()) {
bytes += image.size;
}
for (const [index, page] of state.pageCache) {
if (state.pageCache.size <= PAGE_CACHE_LIMIT && bytes <= PAGE_CACHE_BYTES) {
break;
}
if (protectedPages.has(index)) {
continue;
}
state.pageCache.delete(index);
bytes -= page.cacheBytes;
for (const name of page.imageNames) {
const count = references.get(name) - 1;
references.set(name, count);
if (count === 0) {
const image = state.svgImages.get(name);
bytes -= image.size;
URL.revokeObjectURL(image.url);
state.svgImages.delete(name);
}
}
}
if (!state.pageInFlight.size) releasePageResources();
}
async function loadSVGFonts(fonts, openSeq) {
for (const font of fonts) {
if (openSeq !== state.openSeq) {
return;
}
if (state.svgFonts.has(font.name)) {
continue;
}
const data = await callWASM("ofdgoSVGFontData", font.name);
if (openSeq !== state.openSeq) {
return;
}
const face = new FontFace(font.name, data.bytes, { weight: String(font.weight), style: font.style });
await face.load();
if (openSeq !== state.openSeq) {
return;
}
document.fonts.add(face);
state.svgFonts.set(font.name, face);
}
}
function comparePageRenderTask(a, b) {
return a.priority - b.priority || Math.abs(a.index - state.pageIndex) - Math.abs(b.index - state.pageIndex);
}
function mountPageSVG(index, page, openSeq = state.openSeq) {
if (openSeq !== state.openSeq) {
return;
}
const shell = pageShell(index);
if (!shell) {
return;
}
const surface = shell.querySelector(".page-surface");
const svg = parseSVG(page.svg, `p${openSeq}-${index}`);
svg.classList.add("ofd-svg");
surface.replaceChildren(svg);
surface.append(createTextLayer(page.text));
if (state.editorInfo && displayMode() === "svg") {
mountEditorObjects(index, page, surface);
}
mountAnnotationNotes(index, surface, page.annotations);
mountPageLinks(page, surface);
shell.classList.add("rendered");
renderSearchHighlights(index);
if (state.doc?.pages?.[index]) {
layoutPageShell(shell, state.doc.pages[index]);
}
}
function releaseMediaPlayers(surface) {
for (const [id, entry] of state.mediaPlayers || []) {
if (surface && (entry.surface !== surface || entry.floating)) continue;
releaseMediaPlayer(id, entry);
}
}
function releaseMediaPlayer(id, entry) {
if (state.mediaPlayers?.get(id) !== entry) return;
state.mediaPlayers.delete(id);
for (const [requestID, request] of wasmRequests) {
if (request.mediaID === id) wasmWorker?.postMessage({type: "cancel", id: requestID});
}
entry.finish?.();
closeMediaWindow(entry);
if (entry.element) {
entry.element.pause();
entry.element.removeAttribute("src");
entry.element.load();
entry.element.remove();
}
if (entry.url) URL.revokeObjectURL(entry.url);
}
function closeMediaWindow(entry) {
const dialog = entry.dialog;
if (!dialog) return;
const restoreFocus = dialog.contains(document.activeElement);
entry.dialog = null;
dialog.close();
dialog.remove();
if (restoreFocus && entry.opener?.isConnected) entry.opener.focus({ preventScroll: true });
entry.opener = null;
}
function placeMediaPlayer(id, entry) {
const box = entry.placement;
if (!box) return;
const element = entry.element;
entry.finish?.();
if (box.floating) {
element.removeAttribute("style");
if (!entry.dialog) {
const dialog = document.createElement("dialog");
dialog.className = "form-dialog media-dialog";
const label = entry.kind === "Video" ? "视频" : "音频";
dialog.setAttribute("aria-label", `${label}播放`);
const header = document.createElement("div");
header.className = "form-title";
const title = document.createElement("span");
title.textContent = `${label}播放`;
const close = document.createElement("button");
close.type = "button";
close.className = "small-button";
close.textContent = "关闭";
close.title = `关闭${label}`;
close.setAttribute("aria-label", `关闭${label}`);
close.addEventListener("click", () => releaseMediaPlayer(id, entry));
dialog.addEventListener("keydown", event => {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopPropagation();
releaseMediaPlayer(id, entry);
});
dialog.addEventListener("close", () => {
if (entry.dialog === dialog) releaseMediaPlayer(id, entry);
});
header.append(title, close);
dialog.append(header, element);
entry.opener = document.activeElement;
entry.dialog = dialog;
document.body.append(dialog);
dialog.show();
}
} else {
entry.surface.append(element);
closeMediaWindow(entry);
element.style.left = `${box.x / box.pageWidth * 100}%`;
element.style.width = `${box.width / box.pageWidth * 100}%`;
if (entry.kind === "Video") {
element.style.width = `calc(${box.width / box.pageWidth * 100}% * var(--surface-scale, 1))`;
element.style.top = `${box.y / box.pageHeight * 100}%`;
element.style.height = `calc(${box.height / box.pageHeight * 100}% * var(--surface-scale, 1))`;
} else {
element.style.bottom = `${Math.max(0, 100 - (box.y + box.height) / box.pageHeight * 100)}%`;
}
}
entry.placement = null;
element.currentTime = 0;
}
async function playMediaAction(link, page, surface) {
const action = link.sound || link.movie;
const id = action?.resourceID;
if (!id || !surface || !page) return;
const operator = link.sound ? "Play" : action.operator || "Play";
if (!["Play", "Stop", "Pause", "Resume"].includes(operator)) return;
const players = state.mediaPlayers ||= new Map();
let entry = players.get(id);
if (operator === "Stop" || operator === "Pause") {
if (entry) entry.operation = (entry.operation || 0) + 1;
if (operator === "Stop") {
if (entry) releaseMediaPlayer(id, entry);
} else if (entry) {
entry.paused = true;
entry.element?.pause();
}
return;
}
if (operator === "Resume" && (!entry || entry.element?.ended || !entry.paused && !entry.element?.paused)) return;
const seq = state.openSeq;
try {
if (!entry) {
entry = { surface };
players.set(id, entry);
entry.pending = callWASM("ofdgoExportMedia", id, null).then(data => {
if (seq !== state.openSeq || players.get(id) !== entry) return;
if (!entry.floating && !entry.surface.isConnected) {
releaseMediaPlayer(id, entry);
return;
}
const element = document.createElement(data.kind === "Video" ? "video" : "audio");
entry.kind = data.kind;
entry.element = element;
entry.url = URL.createObjectURL(data.blob);
element.src = entry.url;
element.controls = true;
element.className = "page-media";
element.addEventListener("error", () => {
entry.finish?.();
if (seq === state.openSeq && players.get(id) === entry) setStatus("媒体无法播放");
});
placeMediaPlayer(id, entry);
});
}
if (operator === "Play") {
entry.surface = surface;
const box = link.movie && link.playbackBox ? link.playbackBox : link;
entry.floating = !!link.movie && !link.playbackBox || !!link.sound && !(link.width > 0 && link.height > 0);
entry.placement = { x: box.x, y: box.y, width: box.width, height: box.height, pageWidth: page.width, pageHeight: page.height, floating: entry.floating };
}
entry.paused = false;
const operation = entry.operation = (entry.operation || 0) + 1;
await entry.pending;
if (seq !== state.openSeq || players.get(id) !== entry || operation !== entry.operation || !entry.element?.isConnected) return false;
const element = entry.element;
if (link.sound) {
element.volume = Math.max(0, Math.min(100, action.volume ?? 100)) / 100;
element.loop = !!action.repeat;
}
placeMediaPlayer(id, entry);
let ended;
if (link.sound && action.synchronous && !action.repeat) {
ended = new Promise(resolve => {
entry.finish = event => {
element.removeEventListener("ended", entry.finish);
entry.finish = null;
resolve(event?.type === "ended");
};
element.addEventListener("ended", entry.finish);
});
}
try { await element.play(); } catch (err) { entry.finish?.(); throw err; }
if (ended) return await ended;
} catch (err) {
if (players.get(id) === entry && seq === state.openSeq) {
if (!entry?.element) players.delete(id);
setStatus(err.message || "媒体无法播放");
}
return false;
}
}
function pageLinkTarget(link, page, surface, openAction) {
if (link.sound || link.movie) {
return { href: "#", title: link.sound ? "音频播放" : "视频播放", activate: () => playMediaAction(link, page, surface) };
}
if (link.dest) {
const target = pageIndexByID(state.doc.pages, link.dest.pageID);
return { href: target < 0 ? "#" : `#page-${target + 1}`, title: target < 0 ? "文档链接" : `第${target + 1}页`, activate: () => navigateDestination(link.dest, openAction) };
}
if (link.attachment) {
return { href: "#", title: link.fileName ? `下载附件:${link.fileName}` : "下载附件", activate: () => {
if (!link.fileName) { setStatus("附件不存在"); return; }
return downloadAttachment({ id: link.attachment, fileName: link.fileName });
} };
}
let url;
try { url = new URL(link.uri); } catch { return null; }
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
return { href: url.href, title: url.href, external: true, activate: () => { window.open(url.href, "_blank", "noopener,noreferrer"); } };
}
function mountPageLinks(page, surface) {
const groups = new Map();
const mounted = new Map();
for (const [index, link] of page.links.entries()) {
const target = pageLinkTarget(link, page, surface);
if (!target) continue;
target.order = index;
const key = link.group ? JSON.stringify([link.group, link.x, link.y, link.width, link.height, link.path]) : index;
if (!groups.has(key)) groups.set(key, { link, targets: [] });
groups.get(key).targets.push(target);
}
for (const { link, targets } of groups.values()) {
const anchor = document.createElement("a");
anchor.className = "page-link";
anchor.href = targets[0].href;
anchor.title = targets[0].title;
mounted.set(anchor, { link, targets });
if (!link.group && targets[0].external) {
anchor.target = "_blank";
anchor.rel = "noopener noreferrer";
} else {
anchor.addEventListener("click", async event => {
event.preventDefault();
if (document.body.hasAttribute("aria-busy")) return;
state.openActionTask = null;
const openSeq = state.openSeq;
let actions = targets;
if (event.detail && link.group) {
const matches = new Set();
for (const node of document.elementsFromPoint(event.clientX, event.clientY)) {
const entry = mounted.get(node.closest(".page-link"));
if (entry?.link.group === link.group) entry.targets.forEach(target => matches.add(target));
}
actions = [...matches].sort((a, b) => a.order - b.order);
}
for (const target of actions) {
if (openSeq !== state.openSeq) break;
if (await target.activate() === false) break;
}
});
}
anchor.setAttribute("aria-label", anchor.title);
anchor.style.left = `${link.x / page.width * 100}%`;
anchor.style.top = `${link.y / page.height * 100}%`;
anchor.style.width = `${link.width / page.width * 100}%`;
anchor.style.height = `${link.height / page.height * 100}%`;
if (link.path) {
anchor.classList.add("page-region-link");
const region = document.createElementNS("http://www.w3.org/2000/svg", "svg");
region.setAttribute("viewBox", `${link.x} ${link.y} ${link.width} ${link.height}`);
region.setAttribute("aria-hidden", "true");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", link.path);
region.append(path);
anchor.append(region);
}
surface.append(anchor);
}
}
function destinationPoint(page, x, y) {
if (state.rotation === 90) return { x: page.height - y, y: x };
if (state.rotation === 180) return { x: page.width - x, y: page.height - y };
if (state.rotation === 270) return { x: y, y: page.width - x };
return { x, y };
}
async function navigateDestination(dest, openAction) {
if (document.body.hasAttribute("aria-busy")) return;
if (openAction && state.openActionTask !== openAction) return false;
const index = pageIndexByID(state.doc.pages, dest.pageID);
if (index < 0) {
setStatus("目标页面不存在");
return;
}
if (!["XYZ", "Fit", "FitH", "FitV", "FitR"].includes(dest.type)) {
setStatus("暂不支持此跳转方式");
return;
}
const seq = state.openSeq, scale = state.scale;
await renderPage(index, { fit: false, scroll: false, openAction });
if (openAction && state.openActionTask !== openAction) return false;
if (seq !== state.openSeq || state.pageIndex !== index) return;
const page = state.doc.pages[index], size = pageViewSize(page), space = pageSpace();
const width = el.viewerPanel.clientWidth - space * 2, height = el.viewerPanel.clientHeight - space * 2;
let left = dest.omitLeft ? 0 : (dest.left ?? 0);
let top = dest.omitTop ? 0 : (dest.top ?? 0);
if (dest.type === "Fit") {
fitHeight(false);
scrollToPage(index);
return;
} else if (dest.type === "FitH") {
setScale(fitWidthScale(page), false);
left = 0;
} else if (dest.type === "FitV") {
setScale(height / (size.height * MM_TO_PX), false);
top = 0;
} else if (dest.type === "FitR") {
const a = destinationPoint(page, left, top), b = destinationPoint(page, dest.right, dest.bottom);
if (dest.right <= left || dest.bottom <= top) return;
setScale(Math.min(width / Math.abs(b.x - a.x), height / Math.abs(b.y - a.y)) / MM_TO_PX, false);
} else {
setScale(!dest.omitZoom && dest.zoom > 0 ? dest.zoom : scale, false);
}
const point = destinationPoint(page, left, top);
if (dest.type === "FitR") {
const bottom = destinationPoint(page, dest.right, dest.bottom);
point.x = Math.min(point.x, bottom.x);
point.y = Math.min(point.y, bottom.y);
}
if (state.pageWindow) syncPageWindow(state.pageWindow, state.pageWindow.offsets[index]);
const node = pageShell(index), shell = node.getBoundingClientRect(), viewer = el.viewerPanel.getBoundingClientRect();
el.viewerPanel.scrollLeft += shell.left + (node.clientLeft || 0) - viewer.left - (el.viewerPanel.clientLeft || 0) + point.x * MM_TO_PX * state.scale;
scrollViewerBy(shell.top + (node.clientTop || 0) - viewer.top - (el.viewerPanel.clientTop || 0) + point.y * MM_TO_PX * state.scale);
}
function createTextLayer(text) {
const layer = document.createElement("div");
layer.className = "text-layer";
const measure = textMeasure ||= document.createElement("canvas").getContext("2d");
measure.font = "100px sans-serif";
const widths = new Map();
for (const run of text.runs || []) {
const line = document.createElement("span");
line.className = "text-run";
const chars = Array.from(run.text);
let end = 0;
for (const item of run.spans || []) {
const breaks = chars.slice(end, item.start).filter((char) => char === "\n").join("");
if (breaks) {
line.append(document.createTextNode(breaks));
}
const span = document.createElement("span");
span.textContent = chars.slice(item.start, item.end).join("");
let width = widths.get(span.textContent);
if (width === undefined) {
width = measure.measureText(span.textContent).width || 1;
widths.set(span.textContent, width);
}
const [a, b, c, d, e, f] = item.matrix.map((value) => value * MM_TO_PX);
span.style.transform = `matrix(${a / width}, ${b / width}, ${c / 100}, ${d / 100}, ${e}, ${f})`;
line.append(span);
end = item.end;
}
const breaks = chars.slice(end).filter((char) => char === "\n").join("");
if (breaks) {
line.append(document.createTextNode(breaks));
}
layer.append(line);
}
return layer;
}
function syncSelection() {
const selection = document.getSelection();
if (state.documentSelection && (!selection.rangeCount || !isDocumentSelection(selection.getRangeAt(0)))) {
state.documentSelection = null;
if (state.doc && !document.body.hasAttribute("aria-busy")) {
setStatus(pageStatus(state.pageIndex, state.doc.pageCount));
}
}
for (const index of state.selectedPages) {
unmountPage(index);
}
trimPageCache();
}
function isDocumentSelection(range) {
return range.startContainer === el.svgHost && range.startOffset === 0
&& range.endContainer === el.svgHost && range.endOffset === el.svgHost.childNodes.length;
}
async function selectDocumentText() {
const selection = document.getSelection();
if (state.documentSelection && selection.rangeCount && isDocumentSelection(selection.getRangeAt(0))) {
return;
}
const openSeq = state.openSeq;
const current = { text: null };
const active = () => state.documentSelection === current && openSeq === state.openSeq
&& selection.rangeCount > 0 && isDocumentSelection(selection.getRangeAt(0));
state.documentSelection = current;
const range = document.createRange();
range.selectNodeContents(el.svgHost);
selection.removeAllRanges();
selection.addRange(range);
const pages = [];
const count = state.doc.pageCount;
try {
for (let index = 0; index < count; index += 1) {
setStatus(`正在全选文字 · ${index + 1} / ${count} 页`);
const text = await callWASM("ofdgoPageTextString", index);
if (!active()) {
return;
}
if (text) {
pages.push(text);
}
}
current.text = pages.join("\n");
setStatus(current.text ? "文字已全选" : "暂无文字");
} catch (err) {
if (active()) {
state.documentSelection = null;
selection.removeAllRanges();
showError(err, false);
}
}
}
function copyEditorSelection(event) {
if (!state.editing || !canvasEditor.enabled || !canvasEditor.selected || !el.viewerPanel.contains(event.target)
|| canvasEditor.input || canvasEditor.crop || canvasEditor.drag || document.body.hasAttribute("aria-busy")) return false;
const items = canvasEditor.items().slice().sort((a, b) => a.order - b.order);
const cut = event.type === "cut";
if (state.composite && items.some(item => item.container !== items[0].container)) {
event.preventDefault();
setStatus("请选择同一组内的对象");
return true;
}
if (!canEditObject(canvasEditor.selected, "copy") || cut && !canEditObject(canvasEditor.selected, "delete")) {
event.preventDefault();
setStatus(cut ? "对象不可剪切" : "对象不可复制");
return true;
}
const index = items[0].index, ids = items.map(item => item.id);
const clipboard = { token: crypto.randomUUID(), page: state.doc.pages[index].id, scope: state.composite?.key || "", bounds: selectionBounds(items), x: 0, y: 0, cut,
compound: items.some(item => item.type === "CompositeObject" || item.type === "CompositeGraphicUnit") };
if (canvasEditor.nudge) {
clipboard.bounds.x += canvasEditor.nudge.x;
clipboard.bounds.y += canvasEditor.nudge.y;
}
event.clipboardData.setData(OBJECT_CLIPBOARD_TYPE, clipboard.token);
event.clipboardData.setData("text/plain", items.map(item => item.text || "").filter(Boolean).join("\n"));
event.preventDefault();
state.objectClipboard = clipboard;
const capture = () => {
const openSeq = state.openSeq, revision = state.editorInfo.revision;
return callWASM("ofdgoCaptureObjects", index, ids, clipboard.token, clipboard.scope).then(async () => {
clipboard.cut = false;
if (cut && openSeq === state.openSeq && revision === state.editorInfo?.revision && index === state.pageIndex && state.objectClipboard === clipboard && clipboard.scope === (state.composite?.key || "")) {
clipboard.cut = Boolean(await changeDocument("ofdgoDeleteObjects", { index, id: ids, items }));
}
return true;
}, err => {
if (state.objectClipboard === clipboard) showError(err, false);
return false;
});
};
clipboard.ready = canvasEditor.nudge ? canvasEditor.commitNudge().then(saved => saved ? capture() : false) : capture();
return true;
}
function visiblePageBounds(index) {
const page = state.doc.pages[index], surface = pageShell(index)?.querySelector(".page-surface");
const full = { x: 0, y: 0, width: page.width, height: page.height };
if (!surface) return full;
const rect = surface.getBoundingClientRect(), view = el.viewerPanel.getBoundingClientRect();
const left = Math.max(rect.left, view.left), top = Math.max(rect.top, view.top);
const right = Math.min(rect.right, view.left + el.viewerPanel.clientWidth), bottom = Math.min(rect.bottom, view.top + el.viewerPanel.clientHeight);
if (right <= left || bottom <= top) return full;
const a = pagePoint(left, top, rect, page, state.rotation), b = pagePoint(right, bottom, rect, page, state.rotation);
return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), width: Math.abs(a.x - b.x), height: Math.abs(a.y - b.y) };
}
async function pasteEditorContent(event) {
if (!state.editing || event.target.closest?.("input, textarea, select, [contenteditable]") || formDialogOpen()) return;
event.preventDefault();
const token = event.clipboardData.getData(OBJECT_CLIPBOARD_TYPE);
const files = Array.from(event.clipboardData.files);
const value = event.clipboardData.getData("text/plain").replace(/\t/g, " ");
if ((canvasEditor.nudge || canvasEditor.nudgeCommit) && !await canvasEditor.commitNudge()) return;
if (document.body.hasAttribute("aria-busy") || canvasEditor.input || canvasEditor.crop || canvasEditor.drag) return;
const index = state.pageIndex, page = state.doc.pages[index];
if (token) {
const clipboard = state.objectClipboard;
if (!clipboard || token !== clipboard.token) {
setStatus("复制内容已失效,请重新复制");
return;
}
const openSeq = state.openSeq;
if (!await clipboard.ready || openSeq !== state.openSeq || index !== state.pageIndex || state.objectClipboard !== clipboard) return;
const scope = state.composite?.key || "";
if (clipboard.compound && (scope || clipboard.scope) && (scope !== clipboard.scope || page.id !== clipboard.page)) {
setStatus("复合对象请在原范围粘贴");
return;
}
if (!pageCan("insert")) return;
const visible = visiblePageBounds(index), bounds = clipboard.bounds;
const offset = (clipboard.targetPage || clipboard.page) === page.id ? { x: clipboard.x + (clipboard.cut ? 0 : 3), y: clipboard.y + (clipboard.cut ? 0 : 3) } : { x: 0, y: 0 };
for (const [axis, size] of [["x", "width"], ["y", "height"]]) {
const position = bounds[axis] + offset[axis];
if (position < visible[axis] || position + bounds[size] > visible[axis] + visible[size]) {
const margin = Math.min(20, visible[size] / 10);
offset[axis] = Math.max(visible[axis] + margin, Math.min(position, visible[axis] + visible[size] - margin - bounds[size])) - bounds[axis];
}
}
if (await changeDocument("ofdgoPasteObjects", null, index, token, offset.x, offset.y, state.composite?.key || "")) {
clipboard.targetPage = page.id;
clipboard.x = offset.x;
clipboard.y = offset.y;
clipboard.cut = false;
}
return;
}
if (!pageCan("insert")) return;
const visible = visiblePageBounds(index), margin = Math.min(20, visible.width / 10);
const x = visible.x + margin, y = visible.y + Math.min(20, visible.height / 10), width = visible.width - margin * 2;
if (files.length) {
if (files.length !== 1) {
setStatus("一次粘贴一张图片");
return;
}
if (!/^image\/(png|jpeg)$/.test(files[0].type)) {
setStatus("仅支持PNG、JPG图片");
return;
}
await changeDocument("ofdgoInsertImage", null, index, files[0].arrayBuffer().then(bytes => new Uint8Array(bytes)), x, y, width);
return;
}
if (!value.trim()) return;
const font = state.textFonts[Number(fontPicker.value)];
if (!font || font.disabled) {
setStatus("尚未添加字体");
el.textFontAdd.focus();
return;
}
const style = canvasEditor.selected?.items ? state.textDefaults : currentTextStyle();
const data = readTextFont(font);
await changeDocument("ofdgoInsertText", null, index, value, data, x, y, style.size, style.color,
width, style.wrap, style.align || "left", style.paragraphHeight || 0, style.letterSpacing || 0, ...textIndents(style));
}
function copySelection(event) {
if (sealPreview) return;
if (event.target.closest?.("input, textarea, [contenteditable]")) {
return;
}
const selection = document.getSelection();
if (selection.isCollapsed && copyEditorSelection(event)) return;
if (selection.isCollapsed || !selection.rangeCount) {
return;
}
const range = selection.getRangeAt(0);
if (state.documentSelection && isDocumentSelection(range)) {
event.preventDefault();
if (state.documentSelection.text === null) {
setStatus("正在全选文字");
} else {
event.clipboardData.setData("text/plain", state.documentSelection.text);
}
return;
}
if (!el.svgHost.contains(range.startContainer) || !el.svgHost.contains(range.endContainer)) {
return;
}
const lines = [];
for (const run of el.svgHost.querySelectorAll(".text-run")) {
if (!range.intersectsNode(run)) {
continue;
}
const part = range.cloneRange();
if (!run.contains(part.startContainer)) {
part.setStart(run, 0);
}
if (!run.contains(part.endContainer)) {
part.setEnd(run, run.childNodes.length);
}
const text = part.cloneContents().textContent;
if (text) {
lines.push(text);
}
}
if (!lines.length) {
return;
}
event.clipboardData.setData("text/plain", lines.join("\n"));
event.preventDefault();
}
function markFlowPageError(index, err) {
const shell = pageShell(index);
if (!shell) {
return;
}
shell.classList.add("error");
const placeholder = shell.querySelector(".page-placeholder");
if (placeholder) {
placeholder.textContent = String(err.message || err);
}
}
function queueNearbyPages(index, openSeq = state.openSeq) {
for (let i = Math.max(0, index - 1); i <= Math.min((state.doc?.pageCount || 1) - 1, index + 2); i += 1) {
renderFlowPage(i, { openSeq, priority: 2 });
}
}
function pageShell(index) {
return state.pageWindow?.nodes.get(index) || null;
}
function scrollToPage(index) {
if (state.pageWindow) syncPageWindow(state.pageWindow, state.pageWindow.offsets[index]);
const shell = pageShell(index);
if (shell) {
if (state.pageWindow) {
const shellRect = shell.getBoundingClientRect(), viewerRect = el.viewerPanel.getBoundingClientRect();
const space = state.fitMode === "height" && !state.continuous
? (el.viewerPanel.clientHeight - shellRect.height) / 2 : pageBlockSpace();
scrollViewerBy(shellRect.top - viewerRect.top - space);
} else if (state.fitMode === "height" && !state.continuous) {
shell.scrollIntoView({ block: "center", inline: "nearest" });
} else {
const viewerRect = el.viewerPanel.getBoundingClientRect();
const shellRect = shell.getBoundingClientRect();
const top = el.viewerPanel.scrollTop + shellRect.top - viewerRect.top - pageBlockSpace();
const maxTop = Math.max(0, el.viewerPanel.scrollHeight - el.viewerPanel.clientHeight);
el.viewerPanel.scrollTop = Math.min(maxTop, Math.max(0, top));
}
centerPageInline(shell);
}
}
function centerPageInline(shell) {
const viewerRect = el.viewerPanel.getBoundingClientRect();
const shellRect = shell.getBoundingClientRect();
el.viewerPanel.scrollLeft += shellRect.left + shellRect.width / 2 - viewerRect.left - el.viewerPanel.clientWidth / 2;
}
function schedulePageSync() {
if (state.scrollFrame || !state.doc) {
return;
}
state.scrollFrame = requestAnimationFrame(() => {
state.scrollFrame = 0;
syncCurrentPageFromScroll();
});
}
function syncCurrentPageFromScroll() {
if (state.openActionTask?.navigating) return;
syncPageWindow(state.pageWindow);
const shell = pageShellFromView();
if (!shell) {
return;
}
const nextIndex = Number.parseInt(shell.dataset.pageIndex, 10);
if (Number.isFinite(nextIndex) && nextIndex !== state.pageIndex) {
setCurrentPage(nextIndex);
queueNearbyPages(nextIndex);
scheduleOpenActions();
}
prunePageRenderQueue();
}
function prunePageRenderQueue() {
state.pageRenderQueue = state.pageRenderQueue.filter(task => {
if (task.priority < 2 || Math.abs(task.index - state.pageIndex) <= 2
|| state.pageWindow?.nodes.has(task.index) || state.thumbnailWindow?.nodes.has(task.index)) return true;
state.pageInFlight.delete(task.key);
task.resolve(null);
return false;
});
}
function pageShellFromView() {
const rect = el.viewerPanel.getBoundingClientRect();
const x = rect.left + rect.width / 2;
if (el.viewerPanel.scrollTop + el.viewerPanel.clientHeight >= el.viewerPanel.scrollHeight - 1) {
const shell = pageShell(state.pageIndex);
const bounds = shell?.getBoundingClientRect();
return bounds && bounds.top >= rect.top - 1 && bounds.bottom <= rect.bottom + 1
? shell : el.svgHost.lastElementChild;
}
if (state.continuous) {
return pageShellAtPoint(x, rect.top + 1);
}
return pageShellAtPoint(x, rect.top + rect.height * 0.45)
|| pageShellAtPoint(x, rect.top + rect.height * 0.25)
|| pageShellAtPoint(x, rect.top + rect.height * 0.65);
}
function pageShellAtPoint(x, y) {
return document.elementFromPoint(x, y)?.closest?.(".page-shell") || null;
}
function setCurrentPage(index) {
if (index !== state.pageIndex && !state.openActionTask?.navigating) state.openActionTask = null;
if (state.composite && state.composite.index !== index) resetCompositeScope();
state.pageIndex = index;
if (state.editing) state.openActionPage = state.doc?.pages[index]?.id;
if (state.fitMode === "width") {
state.scale = fitWidthScale(currentPageInfo());
el.zoomLabel.textContent = `${Math.round(state.scale * 100)}%`;
}
updatePageListCurrent();
updateControls();
if (state.doc && !state.exporting) {
setStatus(pageStatus(index, state.doc.pageCount));
}
}
function updatePageListCurrent(force = false) {
if (state.thumbnailWindow) {
if (!force && state.pageListCurrent === state.pageIndex) return;
state.pageListCurrent = state.pageIndex;
if (state.showPages && !el.pageList.hidden) revealThumbnail(state.pageIndex);
}
const current = el.pageList.querySelector(".page-list-item[aria-current]");
if (current) {
if (!force && Number.parseInt(current.dataset.pageIndex, 10) === state.pageIndex) {
return;
}
current.removeAttribute("aria-current");
}
const next = el.pageList.querySelector(`.page-list-item[data-page-index="${state.pageIndex}"]`);
if (next) {
setPageItemCurrent(next, true);
if (state.showPages && !el.pageList.hidden && !state.thumbnailWindow) {
const panel = el.navigationContent.getBoundingClientRect();
const item = next.getBoundingClientRect();
if (item.top < panel.top) {
el.navigationContent.scrollTop += item.top - panel.top;
} else if (item.bottom > panel.bottom) {
el.navigationContent.scrollTop += Math.min(item.top - panel.top, item.bottom - panel.bottom);
}
}
}
}
function setPageItemCurrent(item, current) {
if (current) {
item.setAttribute("aria-current", "page");
return;
}
item.removeAttribute("aria-current");
}
function layoutPages() {
if (!state.doc) {
return;
}
canvasEditor.cancel();
const view = state.pageWindow;
if (!view) return;
const anchor = view.offsets.length > 1 ? pageWindowAnchor(view) : { index: state.pageIndex, offset: 0 };
const gap = Number.parseFloat(getComputedStyle(el.svgHost).rowGap) || 0;
const offsets = new Float64Array(state.doc.pages.length + 1);
const fittedWidth = state.scale * pageViewSize(currentPageInfo()).width;
let width = 1;
for (let index = 0; index < state.doc.pages.length; index++) {
const size = pageViewSize(state.doc.pages[index]);
const scale = state.fitMode === "width" ? fittedWidth / size.width : state.scale;
offsets[index + 1] = offsets[index] + Math.max(1, size.height * MM_TO_PX) * scale + gap;
width = Math.max(width, Math.max(1, size.width * MM_TO_PX) * scale);
}
view.offsets = offsets;
view.gap = gap;
view.total = Math.max(0, offsets.at(-1) - gap);
view.host.style.width = `${width}px`;
for (const [index, shell] of view.nodes) layoutPageShell(shell, state.doc.pages[index]);
syncPageWindow(view, offsets[anchor.index] + anchor.offset);
}
function layoutPageShell(shell, page) {
const width = Math.max(1, page.width * MM_TO_PX);
const height = Math.max(1, page.height * MM_TO_PX);
const size = pageViewSize(page);
const viewWidth = Math.max(1, size.width * MM_TO_PX);
const viewHeight = Math.max(1, size.height * MM_TO_PX);
const scale = state.fitMode === "width" ? state.scale * pageViewSize(currentPageInfo()).width / size.width : state.scale;
shell.style.width = `${viewWidth * scale}px`;
shell.style.height = `${viewHeight * scale}px`;
const surface = shell.querySelector(".page-surface");
if (surface) {
surface.style.width = `${width}px`;
surface.style.height = `${height}px`;
const x = state.rotation === 90 || state.rotation === 180 ? viewWidth : 0;
const y = state.rotation >= 180 ? viewHeight : 0;
surface.style.transform = `scale(${scale}) translate(${x}px, ${y}px) rotate(${state.rotation}deg)`;
surface.style.setProperty("--surface-scale", String(scale));
surface.style.setProperty("--surface-rotation", `${state.rotation}deg`);
surface.classList.toggle("sideways", state.rotation % 180 !== 0);
}
}
function parseSVG(svgText, prefix = "", images = state.svgImages) {
const template = document.createElement("template");
template.innerHTML = svgText.replace(/xlink:href="(ofdgo-image-[a-f0-9]{64})"/g, (_, name) => `xlink:href="${images.get(name).url}"`);
const svg = document.adoptNode(template.content.firstElementChild);
prefixSVGIds(svg, prefix);
return svg;
}
function prefixSVGIds(svg, prefix) {
if (!prefix) {
return;
}
const idMap = new Map();
for (const node of svg.querySelectorAll("[id]")) {
const id = node.getAttribute("id");
if (!id) {
continue;
}
const next = `${prefix}-${id}`;
idMap.set(id, next);
node.setAttribute("id", next);
}
if (idMap.size === 0) {
return;
}
const replaceRef = (value) => {
let next = value.replace(/url\(#([^)]+)\)/g, (match, id) => {
const mapped = idMap.get(id);
return mapped ? `url(#${mapped})` : match;
});
if (next.startsWith("#")) {
const mapped = idMap.get(next.slice(1));
if (mapped) {
next = `#${mapped}`;
}
}
return next;
};
const attrs = ["clip-path", "fill", "filter", "href", "marker-end", "marker-mid", "marker-start", "mask", "stroke", "style", "xlink:href"];
for (const attr of attrs) {
for (const node of svg.querySelectorAll(`[${attr === "xlink:href" ? "*|href" : attr}*="#"]`)) {
const value = node.getAttribute(attr);
if (!value) {
continue;
}
const next = replaceRef(value);
if (next !== value) {
node.setAttribute(attr, next);
}
}
}
}
function focusSearch() {
if (!state.showPages) {
toggleSidebar("pages");
}
showNavigation(el.searchTab);
el.searchInput.focus({ preventScroll: true });
el.searchInput.select();
}
function showNavigation(selected) {
const scrollTop = el.navigationContent.scrollTop;
for (const [tab, panel] of [[el.pagesTab, el.pageList], [el.outlinesTab, el.outlineList], [el.annotationsTab, el.annotationsPanel], [el.searchTab, el.searchPanel]]) {
if (tab.getAttribute("aria-selected") === "true") {
state.navigationScroll.set(tab, scrollTop);
}
const active = tab === selected;
panel.hidden = !active;
tab.setAttribute("aria-selected", String(active));
tab.tabIndex = active ? 0 : -1;
}
el.pageSelectionTools.hidden = !state.editing || !state.pageMultiSelect || selected !== el.pagesTab;
el.navigationContent.scrollTop = state.navigationScroll.get(selected) || 0;
if (selected === el.pagesTab) syncThumbnailWindow();
if (selected === el.annotationsTab) loadAnnotationSummary();
}
async function loadAnnotationSummary() {
if (!state.doc || !state.ready) return;
const seq = state.openSeq;
if (state.annotationSummary?.seq === seq) { renderAnnotationSummary(); return; }
const summary = {seq, items: [], loading: true};
state.annotationSummary = summary;
el.annotationsList.replaceChildren();
el.annotationsStatus.textContent = "正在读取注解";
try {
const items = await callWASM("ofdgoAnnotationInfos");
if (seq !== state.openSeq || state.annotationSummary !== summary) return;
summary.items = items || [];
const labels = {Path: "批注", Highlight: "高亮", Stamp: "印章", Watermark: "水印", Link: "链接"};
const kind = el.annotationsKind.value;
el.annotationsKind.replaceChildren(new Option("全部", ""), ...[...new Set(summary.items.map(item => item.type))].map(type => new Option(labels[type] || type, type)));
el.annotationsKind.value = [...el.annotationsKind.options].some(option => option.value === kind) ? kind : "";
summary.loading = false;
renderAnnotationSummary();
} catch (error) {
if (seq === state.openSeq && state.annotationSummary === summary) {
state.annotationSummary = null;
el.annotationsStatus.textContent = error.message;
}
}
}
function renderAnnotationSummary() {
const summary = state.annotationSummary;
if (!summary || summary.loading || summary.seq !== state.openSeq) return;
const query = el.annotationsFilter.value.trim().toLocaleLowerCase();
const kind = el.annotationsKind.value;
const labels = {Path: "批注", Highlight: "高亮", Stamp: "印章", Watermark: "水印", Link: "链接"};
const items = summary.items.filter(item => (!kind || item.type === kind) && `${item.remark || ""}\n${item.creator || ""}\n${item.id}`.toLocaleLowerCase().includes(query));
el.annotationsStatus.textContent = summary.items.length ? `${items.length} / ${summary.items.length}` : "";
el.annotationsList.replaceChildren(...items.map(item => {
const row = document.createElement("div");
row.className = "annotation-summary";
const target = document.createElement("button");
target.type = "button";
target.className = "annotation-summary-target";
target.title = "定位注解";
const heading = document.createElement("span"), content = document.createElement("span");
heading.textContent = `第${item.page}页 · ${labels[item.type] || item.type}${item.visible ? "" : " · 隐藏"}`;
content.textContent = item.remark || item.id;
target.append(heading, content);
target.addEventListener("click", () => focusPageRegion(item, "注解", "center"));
row.append(target);
appendInfoLine(row, "作者", item.creator);
appendInfoLine(row, "时间", formatDocumentTime(item.lastModDate));
if (state.editing) {
const edit = document.createElement("button");
edit.type = "button";
edit.className = "small-button";
edit.textContent = "修改";
edit.addEventListener("click", () => {
if (!state.editing || document.body.hasAttribute("aria-busy") || summary.seq !== state.openSeq) return;
editAnnotationDetails({...item, id: `annotation:${item.id}`, index: item.page - 1, type: "Annotation", fromSummary: true});
});
row.append(edit);
}
return row;
}));
}
function renderOutlines(reset = true) {
const selected = !reset && [el.pagesTab, el.outlinesTab, el.annotationsTab, el.searchTab].find(tab => tab.getAttribute("aria-selected") === "true");
const scrollTop = reset ? 0 : el.navigationContent.scrollTop;
if (reset) {
state.navigationScroll.clear();
state.outlineExpanded.clear();
state.outlineSelection = null;
}
const outlines = state.doc.outlines || [];
el.pageListTitle.hidden = true;
el.navigationTabs.hidden = false;
el.outlinesTab.hidden = outlines.length === 0 && !state.editing;
el.outlineList.replaceChildren();
if (state.editing) {
const tools = document.createElement("div");
tools.className = "outline-tools";
for (const [action, label] of [["add", "新增"], ["update", "修改"], ["move", "移动"], ["delete", "删除"]]) {
const button = document.createElement("button");
button.className = "button";
button.type = "button";
button.textContent = label;
button.dataset.outlineAction = action;
button.disabled = action !== "add" && !state.outlineSelection?.length;
editorClick(button, () => openOutlinePanel(action));
tools.append(button);
}
el.outlineList.append(tools);
}
if (outlines.length > 0) {
el.outlineList.append(createOutlineList(outlines));
}
el.navigationContent.scrollTop = scrollTop;
showNavigation(selected && !selected.hidden ? selected : el.pagesTab);
}
function resetSearch(clearInput = true) {
state.searchSeq += 1;
state.searchQuery = "";
state.searchMatches = [];
state.searchIndex = -1;
if (clearInput) {
el.searchInput.value = "";
}
el.searchStatus.textContent = "";
el.searchResults.replaceChildren();
updateSearchCount();
clearSearchHighlights();
}
async function searchDocument() {
if (!state.doc || document.body.hasAttribute("aria-busy")) {
return;
}
const query = el.searchInput.value.trim();
if (query && query === state.searchQuery) {
await selectSearchMatch(state.searchIndex + 1);
return;
}
resetSearch(false);
if (!query) {
return;
}
state.searchQuery = query;
const seq = state.searchSeq;
const openSeq = state.openSeq;
const pageCount = state.doc.pageCount;
try {
for (let page = 0; page < pageCount; page += 1) {
const matches = await callWASM("ofdgoSearchPage", page, query);
if (seq !== state.searchSeq || openSeq !== state.openSeq) {
return;
}
el.searchStatus.textContent = `正在搜索文字 · ${page + 1} / ${pageCount} 页`;
if (!matches?.length) {
continue;
}
for (const match of matches) {
state.searchMatches.push({ ...match, page });
}
renderSearchResults();
}
el.searchStatus.textContent = state.searchMatches.length ? "搜索完成" : "暂无结果";
if (state.searchIndex < 0 && state.searchMatches.length && !el.searchPanel.hidden && state.showPages) {
await selectSearchMatch(0);
}
} catch (err) {
if (seq === state.searchSeq && openSeq === state.openSeq) {
state.searchQuery = "";
el.searchStatus.textContent = "搜索失败";
showError(err, false);
}
}
}
function updateSearchCount() {
const count = state.searchMatches.length;
el.searchCount.textContent = `${state.searchIndex + 1} / ${count}`;
el.searchPrev.disabled = count === 0;
el.searchNext.disabled = count === 0;
}
function renderSearchResults() {
const start = Math.floor(Math.max(0, state.searchIndex) / 50) * 50;
if (el.searchResults.firstElementChild && Number(el.searchResults.firstElementChild.dataset.matchIndex) !== start) {
el.searchResults.replaceChildren();
}
for (let i = start + el.searchResults.childElementCount; i < Math.min(start + 50, state.searchMatches.length); i += 1) {
const match = state.searchMatches[i];
const button = document.createElement("button");
button.type = "button";
button.className = "search-result";
button.dataset.matchIndex = String(i);
const page = document.createElement("span");
page.className = "search-page";
page.textContent = `第 ${match.page + 1} 页`;
const text = document.createElement("span");
const mark = document.createElement("mark");
mark.textContent = match.text;
text.append(match.before, mark, match.after);
button.append(page, text);
button.addEventListener("click", () => selectSearchMatch(i));
el.searchResults.append(button);
}
for (const button of el.searchResults.children) {
button.setAttribute("aria-current", String(Number(button.dataset.matchIndex) === state.searchIndex));
}
updateSearchCount();
}
async function selectSearchMatch(index) {
const count = state.searchMatches.length;
if (!count || document.body.hasAttribute("aria-busy")) {
return;
}
index = (index + count) % count;
state.searchIndex = index;
const match = state.searchMatches[index];
const seq = state.searchSeq;
const openSeq = state.openSeq;
clearSearchHighlights();
renderSearchResults();
if (!await renderPage(match.page, { fit: false, scroll: false })) {
return;
}
await nextFrame();
if (seq !== state.searchSeq || openSeq !== state.openSeq || state.searchIndex !== index || state.pageIndex !== match.page) {
return;
}
renderSearchHighlights(match.page);
const mark = pageShell(match.page).querySelector(".search-highlight");
if (mark) {
const box = mark.getBoundingClientRect();
const viewer = el.viewerPanel.getBoundingClientRect();
scrollViewerBy(box.top + box.height / 2 - viewer.top - el.viewerPanel.clientHeight / 2);
if (box.left < viewer.left) {
el.viewerPanel.scrollLeft += box.left - viewer.left;
} else if (box.right > viewer.left + el.viewerPanel.clientWidth) {
el.viewerPanel.scrollLeft += box.right - viewer.left - el.viewerPanel.clientWidth;
}
} else {
scrollToPage(match.page);
}
if (state.editing) {
const node = [...pageShell(match.page).querySelectorAll(".edit-object")].find(node => canvasEditor.nodes.get(node)?.id === match.id);
const item = node && canvasEditor.nodes.get(node);
if (item?.type === "TextObject") {
state.selectObjects = true;
setPan(false);
canvasEditor.setTool("");
canvasEditor.select(item);
updateControls();
} else canvasEditor.select(null);
}
const button = el.searchResults.querySelector("[aria-current=true]");
const row = button.getBoundingClientRect();
const panel = el.searchResults.getBoundingClientRect();
if (row.top < panel.top) {
el.searchResults.scrollTop += row.top - panel.top;
} else if (row.bottom > panel.bottom) {
el.searchResults.scrollTop += row.bottom - panel.bottom;
}
return true;
}
function clearSearchHighlights() {
for (const mark of el.svgHost.querySelectorAll(".search-highlight")) {
mark.remove();
}
}
function renderSearchHighlights(index) {
const match = state.searchMatches[state.searchIndex];
if (!match || match.page !== index) {
return;
}
const surface = pageShell(index).querySelector(".page-surface");
for (const mark of surface.querySelectorAll(".search-highlight")) {
mark.remove();
}
for (const box of match.boxes || []) {
const mark = document.createElement("span");
mark.className = "search-highlight";
mark.style.left = `${box.X * MM_TO_PX}px`;
mark.style.top = `${box.Y * MM_TO_PX}px`;
mark.style.width = `${box.W * MM_TO_PX}px`;
mark.style.height = `${box.H * MM_TO_PX}px`;
surface.append(mark);
}
}
function scheduleOpenActions() {
if (!state.openActionEnabled || state.openActionFrame) return;
state.openActionFrame = requestAnimationFrame(() => {
state.openActionFrame = 0;
activateOpenActions();
});
}
async function activateOpenActions() {
if (!state.openActionEnabled || !state.doc || state.editing || state.exporting || document.body.hasAttribute("aria-busy")) return;
const pageID = state.doc.pages[state.pageIndex]?.id;
if (!pageID || !state.openActionDocument && state.openActionPage === pageID) return;
const task = { sequence: state.openSeq, seen: new Set(), navigating: false };
state.openActionTask = task;
const current = () => task === state.openActionTask && task.sequence === state.openSeq && !state.editing;
let documentOpened = state.openActionDocument;
state.openActionDocument = false;
state.openActionPage = pageID;
try {
while (current()) {
const index = state.pageIndex;
const id = state.doc.pages[index]?.id;
if (!id || !documentOpened && task.seen.has(id)) break;
if (!documentOpened) {
task.seen.add(id);
state.openActionPage = id;
}
const page = await renderFlowPage(index, { openSeq: task.sequence, priority: 0, throwError: true });
if (!page || !current()) break;
const links = documentOpened ? await callWASM("ofdgoOpenLinks") : await callWASM("ofdgoOpenLinks", index);
if (!current()) break;
for (const link of links) {
if (!current()) break;
const info = state.doc.pages[state.pageIndex];
const surface = pageShell(state.pageIndex)?.querySelector(".page-surface");
const target = pageLinkTarget(link, info, surface, task);
if (!target) continue;
if ((target.external || link.attachment) && !window.confirm(link.attachment
? `文档请求下载附件,是否继续?\n${link.fileName || ""}` : `文档请求打开链接,是否继续?\n${target.href}`)) return;
task.navigating = !!link.dest;
if (await target.activate() === false) return;
if (task.navigating) await nextFrame();
task.navigating = false;
}
if (documentOpened) documentOpened = false;
else if (state.doc.pages[state.pageIndex]?.id === id) break;
}
} catch (err) {
if (current()) setStatus(err.message || "动作无法执行");
} finally {
if (task === state.openActionTask) {
state.openActionPage = state.doc?.pages[state.pageIndex]?.id;
state.openActionTask = null;
}
}
}
async function activateOutline(path) {
if (document.body.hasAttribute("aria-busy") || state.editing) return;
state.openActionTask = null;
const sequence = state.openSeq;
const request = ++state.outlineSeq;
try {
const links = await callWASM("ofdgoOutlineLinks", path);
for (const link of links) {
if (sequence !== state.openSeq || request !== state.outlineSeq || state.editing) break;
const page = state.doc?.pages[state.pageIndex];
const surface = pageShell(state.pageIndex)?.querySelector(".page-surface");
const target = pageLinkTarget(link, page, surface);
if (target && await target.activate() === false) break;
}
} catch (err) {
if (sequence === state.openSeq && request === state.outlineSeq && !state.editing) setStatus(err.message || "目录无法打开");
}
}
function createOutlineList(outlines, path = []) {
const list = document.createElement("ul");
list.className = "outline-list";
for (const [index, outline] of outlines.entries()) {
const current = [...path, index];
const item = document.createElement("li");
const hasChildren = outline.children?.length > 0;
const row = document.createElement(hasChildren ? "summary" : "div");
if (!hasChildren) {
row.className = "outline-leaf";
}
const link = document.createElement(outline.actionCount || outline.page || state.editing ? "button" : "span");
link.className = "outline-link";
link.classList.toggle("selected", state.editing && JSON.stringify(current) === JSON.stringify(state.outlineSelection));
const title = document.createElement("span");
title.textContent = outline.title;
link.append(title);
if (outline.page) {
link.type = "button";
const page = document.createElement("span");
page.className = "outline-page";
page.textContent = String(outline.page);
page.setAttribute("aria-label", `第 ${outline.page} 页`);
link.append(page);
}
if (outline.actionCount || outline.page || state.editing) {
link.type = "button";
link.addEventListener("keydown", async event => {
if (!state.editing || event.defaultPrevented || event.isComposing || !event.altKey || event.ctrlKey || event.metaKey || event.shiftKey
|| !["ArrowUp", "ArrowDown"].includes(event.key)) return;
event.preventDefault();
const target = index + (event.key === "ArrowUp" ? -1 : 1);
if (event.repeat || document.body.hasAttribute("aria-busy") || target < 0 || target >= outlines.length) return;
if (await changeDocument("ofdgoMoveOutline", null, current, path, target)) {
const selected = el.outlineList.querySelector(".outline-link.selected");
selected?.focus({ preventScroll: true });
selected?.scrollIntoView({ block: "nearest" });
}
});
link.addEventListener("click", (event) => {
event.preventDefault();
if (state.editing) {
state.outlineSelection = current;
for (const node of el.outlineList.querySelectorAll(".outline-link")) node.classList.toggle("selected", node === link);
for (const button of el.outlineList.querySelectorAll("[data-outline-action]")) button.disabled = false;
if (outline.page) renderPage(outline.page - 1);
return;
}
activateOutline(current);
});
}
row.append(link);
if (hasChildren) {
const details = document.createElement("details");
const key = JSON.stringify(current);
details.open = state.outlineExpanded.get(key) ?? outline.expanded;
details.addEventListener("toggle", () => {
if (details.isConnected) state.outlineExpanded.set(key, details.open);
});
details.append(row, createOutlineList(outline.children, current));
item.append(details);
} else {
item.append(row);
}
list.append(item);
}
return list;
}
const pageIndexes = new WeakMap();
function pageIndexByID(pages, id) {
let indexes = pageIndexes.get(pages);
if (!indexes) {
indexes = new Map(pages.map((page, position) => [referenceIDKey(page.id), {page, position}]));
pageIndexes.set(pages, indexes);
}
const entry = indexes.get(referenceIDKey(id));
return entry ? entry.page.index ?? entry.position : -1;
}
function referenceIDKey(id) {
const value = String(id ?? ""), trimmed = value.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, "");
return /^\d+$/.test(trimmed) && Number(trimmed) > 0 && Number(trimmed) <= 4294967295 ? trimmed.replace(/^0+(?=\d)/, "") : value;
}
function selectedPageIndexes() {
if (!state.pageSelection.size) return state.pageMultiSelect ? [] : [state.pageIndex];
return [...state.pageSelection].map(id => pageIndexByID(state.doc.pages, id)).filter(index => index >= 0).sort((a, b) => a - b);
}
function changeSelectedPages(action, direction = 0) {
const indexes = selectedPageIndexes();
if (!indexes.length) return;
if (indexes.length === 1) return changeDocument("ofdgoChangePage", null, action, indexes[0], ...(direction ? [indexes[0] + direction] : []));
const at = direction < 0 ? indexes[0] - 1 : indexes.at(-1) + 2;
return changeDocument("ofdgoBatchPages", null, action, selectedPageRange(), ...(direction ? [at] : []));
}
function selectedPageRange(indexes = selectedPageIndexes()) {
const pages = indexes.map(index => index + 1);
const ranges = [];
for (let i = 0; i < pages.length; i++) {
const first = pages[i];
while (pages[i + 1] === pages[i] + 1) i++;
ranges.push(first === pages[i] ? String(first) : `${first}-${pages[i]}`);
}
return ranges.join(",");
}
function syncPageSelection() {
if (!state.editing) state.pageMultiSelect = false;
el.pageSelectionTools.hidden = !state.editing || !state.pageMultiSelect || el.pageList.hidden;
el.pageSelectionCount.textContent = `已选${state.pageSelection.size}页`;
const allSelected = state.pageSelection.size === state.doc?.pageCount;
el.selectAllPages.textContent = allSelected ? "取消" : "全选";
el.selectAllPages.title = allSelected ? "取消全选" : "全选页面";
el.selectAllPages.setAttribute("aria-label", el.selectAllPages.title);
for (const button of el.pageList.children) {
if (state.editing) button.setAttribute("aria-pressed", String(state.pageSelection.has(state.doc.pages[Number(button.dataset.pageIndex)].id)));
else button.removeAttribute("aria-pressed");
}
}
function selectThumbnailPage(event, index) {
if (document.body.hasAttribute("aria-busy")) return;
if (event.pointerId === state.pageTouchPointer) {
state.pageTouchPointer = null;
return;
}
if (state.editing) {
const page = state.doc.pages[index], additive = event.ctrlKey || event.metaKey || state.pageMultiSelect;
if (!additive) state.pageSelection.clear();
if (event.shiftKey) {
let anchor = pageIndexByID(state.doc.pages, state.pageSelectionAnchor);
if (anchor < 0) anchor = state.pageIndex;
for (let i = Math.min(anchor, index); i <= Math.max(anchor, index); i++) state.pageSelection.add(state.doc.pages[i].id);
} else {
if (additive && state.pageSelection.has(page.id)) state.pageSelection.delete(page.id);
else state.pageSelection.add(page.id);
state.pageSelectionAnchor = page.id;
}
syncPageSelection();
updateControls();
if (additive || event.shiftKey) return;
}
return renderPage(index);
}
el.selectAllPages.addEventListener("click", () => {
if (document.body.hasAttribute("aria-busy")) return;
state.pageSelection = new Set(state.pageSelection.size === state.doc.pageCount ? [] : state.doc.pages.map(page => page.id));
syncPageSelection();
updateControls();
});
el.finishPageSelection.addEventListener("click", () => {
state.pageMultiSelect = false;
syncPageSelection();
updateControls();
});
el.pageList.addEventListener("pointerdown", event => {
cancelPageTouch?.();
state.pageTouchPointer = null;
if (!state.editing || event.pointerType !== "touch" || !event.isPrimary || document.body.hasAttribute("aria-busy")) return;
const button = event.target.closest(".page-list-item");
if (!button || event.target.closest(".thumb-grip")) return;
const seq = state.openSeq, pointer = event.pointerId;
const timer = setTimeout(() => {
if (seq !== state.openSeq || !state.editing || document.body.hasAttribute("aria-busy")) return;
if (!state.pageMultiSelect) state.pageSelection.clear();
state.pageMultiSelect = true;
state.pageTouchPointer = pointer;
state.pageSelection.add(state.doc.pages[Number(button.dataset.pageIndex)].id);
syncPageSelection();
updateControls();
}, 450);
const finish = next => {
if (next.pointerId !== pointer) return;
cancelPageTouch?.();
};
cancelPageTouch = () => {
clearTimeout(timer);
el.pageList.removeEventListener("pointermove", move);
el.pageList.removeEventListener("pointerup", finish);
el.pageList.removeEventListener("pointercancel", finish);
el.pageList.removeEventListener("lostpointercapture", finish);
cancelPageTouch = null;
};
const move = next => {
if (Math.hypot(next.clientX - event.clientX, next.clientY - event.clientY) > 8) finish(next);
};
el.pageList.addEventListener("pointermove", move);
el.pageList.addEventListener("pointerup", finish);
el.pageList.addEventListener("pointercancel", finish);
el.pageList.addEventListener("lostpointercapture", finish);
});
el.pageList.addEventListener("contextmenu", event => {
if (state.pageMultiSelect) event.preventDefault();
});
el.pageList.addEventListener("keydown", event => {
const button = event.target?.closest(".page-list-item");
if (state.thumbnailWindow && button && !event.altKey && !event.ctrlKey && !event.metaKey && !event.isComposing) {
const index = Number(button.dataset.pageIndex);
const next = { ArrowUp: index - 1, ArrowDown: index + 1, Home: 0, End: state.doc.pageCount - 1,
Tab: index + (event.shiftKey ? -1 : 1) }[event.key];
if (next >= 0 && next < state.doc.pageCount) {
event.preventDefault();
revealThumbnail(next);
state.thumbnailWindow.nodes.get(next)?.focus({ preventScroll: true });
return;
}
}
if (!state.editing || event.isComposing || document.body.hasAttribute("aria-busy")) return;
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "a") {
state.pageSelection = new Set(state.doc.pages.map(page => page.id));
} else if (event.key === "Escape") {
state.pageSelection.clear();
state.pageSelectionAnchor = null;
state.pageMultiSelect = false;
} else return;
event.preventDefault();
event.stopPropagation();
syncPageSelection();
updateControls();
});
function renderPageList() {
el.pageList.replaceChildren();
state.thumbnailWindow = null;
state.pageListCurrent = null;
state.visibleThumbnails.clear();
if (!state.doc) {
return;
}
if (state.thumbnailObserver) {
state.thumbnailObserver.disconnect();
}
const ids = new Set(state.doc.pages.map(page => page.id));
for (const id of state.pageSelection) if (!ids.has(id) || !state.editing) state.pageSelection.delete(id);
state.thumbnailWindow = createPageWindow(el.pageList, el.navigationContent, 180, createThumbnail, releaseThumbnail, observeThumbnail);
layoutThumbnails();
syncPageSelection();
}
function createThumbnail(page) {
const openSeq = state.openSeq;
const button = document.createElement("button");
button.type = "button";
button.className = "page-list-item";
button.dataset.pageIndex = String(page.index);
button.setAttribute("aria-posinset", String(page.index + 1));
button.setAttribute("aria-setsize", String(state.doc.pageCount));
if (state.editing) button.setAttribute("aria-pressed", String(state.pageSelection.has(page.id)));
button.title = `${formatSize(page.width)} x ${formatSize(page.height)} mm`;
setPageItemCurrent(button, page.index === state.pageIndex);
layoutThumbnail(button, page);
const thumb = document.createElement("span");
thumb.className = "thumb-paper";
thumb.setAttribute("aria-hidden", "true");
setThumbnailContent(thumb, state.pageCache.get(page.index)?.svg, page.index, openSeq);
const label = document.createElement("span");
label.className = "thumb-label";
label.textContent = `第 ${page.index + 1} 页`;
const size = document.createElement("span");
size.className = "thumb-size";
size.textContent = `${formatSize(page.width)} x ${formatSize(page.height)} mm`;
button.append(thumb, label, size);
if (state.editing && pageCan("move", page.index)) enablePageDrag(button, page.index);
button.addEventListener("click", event => selectThumbnailPage(event, page.index));
button.style.height = `${state.thumbnailWindow.offsets[page.index + 1] - state.thumbnailWindow.offsets[page.index] - 12}px`;
return button;
}
function releaseThumbnail(button, index) {
if (state.pageDragIndex === index || button.contains(document.activeElement)) return false;
state.thumbnailObserver?.unobserve(button);
state.visibleThumbnails.delete(index);
return true;
}
function layoutThumbnails() {
const view = state.thumbnailWindow;
if (!view) return;
const visible = !el.pageList.hidden && state.showPages;
const anchor = visible && view.offsets.length > 1 ? pageWindowAnchor(view) : view.anchor || { index: state.pageIndex, offset: 0 };
const width = el.navigationContent.clientWidth || view.width || 180;
view.width = width;
view.rotation = state.rotation;
const offsets = new Float64Array(state.doc.pages.length + 1);
for (let index = 0; index < state.doc.pages.length; index++) {
const size = pageViewSize(state.doc.pages[index]);
const ratio = size.width > 0 && size.height > 0 ? size.height / size.width : 1 / 0.707;
offsets[index + 1] = offsets[index] + Math.max(1, width - 20) * ratio + 70 + 12;
}
view.offsets = offsets;
view.gap = 12;
view.total = Math.ceil(offsets.at(-1));
for (const [index, button] of view.nodes) {
layoutThumbnail(button, state.doc.pages[index]);
button.style.height = `${offsets[index + 1] - offsets[index] - 12}px`;
}
view.pendingTop = offsets[anchor.index] + anchor.offset;
view.host.style.height = `${Math.min(SCROLL_SEGMENT_SIZE, view.total)}px`;
if (visible) {
syncPageWindow(view, view.pendingTop);
view.pendingTop = null;
}
}
function syncThumbnailWindow() {
const view = state.thumbnailWindow;
if (!view || el.pageList.hidden || !state.showPages) return;
if (el.navigationContent.clientWidth && (view.width !== el.navigationContent.clientWidth || view.rotation !== state.rotation)) layoutThumbnails();
syncPageWindow(view, view.pendingTop ?? null);
view.pendingTop = null;
prunePageRenderQueue();
}
function revealThumbnail(index) {
syncThumbnailWindow();
const view = state.thumbnailWindow;
if (!view) return;
const top = pageWindowTop(view);
const start = view.offsets[index], end = view.offsets[index + 1] - view.gap;
if (start < top || end > top + el.navigationContent.clientHeight) {
const target = start < top ? Math.floor(start) : Math.min(start, Math.ceil(end - el.navigationContent.clientHeight));
syncPageWindow(view, target);
}
}
function resetThumbnails() {
state.visibleThumbnails.clear();
state.thumbnailInFlight.clear();
if (state.thumbnailObserver) {
state.thumbnailObserver.disconnect();
state.thumbnailObserver = null;
}
}
function pageDropIndex(indexes, target, after) {
const position = target + Number(after);
return position - indexes.filter(index => index < position).length;
}
function selectDragPages(index) {
if (!state.pageSelection.has(state.doc.pages[index].id)) {
state.pageSelection = new Set([state.doc.pages[index].id]);
state.pageSelectionAnchor = state.doc.pages[index].id;
syncPageSelection();
updateControls();
}
return selectedPageIndexes();
}
function enablePageDrag(button, index) {
const grip = document.createElement("span");
grip.className = "thumb-grip";
grip.title = "拖动排序";
grip.setAttribute("aria-hidden", "true");
button.append(grip);
grip.addEventListener("click", event => event.stopPropagation());
grip.addEventListener("pointerdown", event => {
if (event.button !== 0 || document.body.hasAttribute("aria-busy")) return;
event.preventDefault();
event.stopPropagation();
const seq = state.openSeq, pointer = event.pointerId;
const indexes = selectDragPages(index);
state.pageDragIndex = index;
let x = event.clientX, y = event.clientY, position = null, marker = null, frame = 0, dragging = false;
const clearMarker = () => { marker?.classList.remove("drop-before", "drop-after"); marker = null; };
const locate = () => {
clearMarker();
position = null;
const node = document.elementFromPoint(x, y)?.closest(".page-list-item");
if (!node || !el.pageList.contains(node)) return;
const rect = node.getBoundingClientRect(), after = y > rect.top + rect.height / 2;
const target = Number(node.dataset.pageIndex), to = pageDropIndex(indexes, target, after);
if (indexes.some((index, offset) => index !== to + offset)) {
position = target + Number(after);
marker = node;
marker.classList.add(after ? "drop-after" : "drop-before");
}
};
const tick = () => {
if (seq !== state.openSeq || !state.editing) { finish(false); return; }
if (dragging) {
const rect = el.navigationContent.getBoundingClientRect();
const delta = y < rect.top + 48 ? -10 : y > rect.bottom - 48 ? 10 : 0;
if (delta) {
if (state.thumbnailWindow) scrollPageWindowBy(state.thumbnailWindow, delta);
else el.navigationContent.scrollTop += delta;
}
syncThumbnailWindow();
locate();
}
frame = requestAnimationFrame(tick);
};
const move = next => {
if (next.pointerId !== pointer) return;
x = next.clientX; y = next.clientY;
dragging ||= Math.hypot(x - event.clientX, y - event.clientY) > 4;
button.classList.toggle("reordering", dragging);
if (dragging) locate();
};
const finish = async commit => {
state.pageDragIndex = null;
cancelAnimationFrame(frame);
grip.removeEventListener("pointermove", move);
grip.removeEventListener("pointerup", up);
grip.removeEventListener("pointercancel", cancel);
grip.removeEventListener("lostpointercapture", cancel);
clearMarker();
button.classList.remove("reordering");
if (grip.hasPointerCapture(pointer)) grip.releasePointerCapture(pointer);
if (commit && dragging && position !== null && seq === state.openSeq && await canvasEditor.commitText() && await canvasEditor.commitCrop()) {
await changeDocument("ofdgoBatchPages", null, "move", selectedPageRange(indexes), position);
}
};
const up = next => { if (next.pointerId === pointer) finish(true); };
const cancel = () => finish(false);
grip.setPointerCapture(pointer);
grip.addEventListener("pointermove", move);
grip.addEventListener("pointerup", up);
grip.addEventListener("pointercancel", cancel);
grip.addEventListener("lostpointercapture", cancel);
frame = requestAnimationFrame(tick);
});
button.addEventListener("keydown", async event => {
if (!event.altKey || !["ArrowUp", "ArrowDown"].includes(event.key) || document.body.hasAttribute("aria-busy")) return;
event.preventDefault();
const indexes = selectDragPages(index), direction = event.key === "ArrowUp" ? -1 : 1;
const target = direction < 0 ? indexes[0] - 1 : indexes.at(-1) + 1;
if (target >= 0 && target < state.doc.pageCount && await canvasEditor.commitText() && await canvasEditor.commitCrop()) {
const focus = (direction < 0 ? target : target - indexes.length + 1) + indexes.indexOf(index);
if (await changeSelectedPages("move", direction)) {
revealThumbnail(focus);
el.pageList.querySelector(`[data-page-index="${focus}"]`)?.focus({ preventScroll: true });
}
}
});
}
function layoutThumbnail(button, page) {
if (page.width <= 0 || page.height <= 0) {
return;
}
const size = pageViewSize(page);
button.style.setProperty("--thumb-ratio", `${size.width} / ${size.height}`);
button.style.setProperty("--thumb-width", `${page.width / size.width * 100}%`);
button.style.setProperty("--thumb-height", `${page.height / size.height * 100}%`);
button.style.setProperty("--thumb-rotation", `${state.rotation}deg`);
}
function setThumbnailContent(container, svgText, index, openSeq = state.openSeq) {
const renderKey = `${openSeq}:${index}`;
if (svgText && container.dataset.renderKey === renderKey) {
return;
}
delete container.dataset.renderKey;
container.replaceChildren();
if (!svgText) {
container.classList.add("pending");
container.textContent = String(index + 1);
return;
}
try {
const svg = parseSVG(svgText, `t${openSeq}-${index}`);
svg.classList.add("thumb-svg");
svg.setAttribute("aria-hidden", "true");
container.classList.remove("pending", "error");
container.append(svg);
container.dataset.renderKey = renderKey;
} catch {
container.classList.add("error");
container.textContent = String(index + 1);
}
}
function observeThumbnail(button, index, openSeq = state.openSeq) {
if (!button) return;
const observer = thumbnailObserver();
if (observer) {
observer.observe(button);
}
if (index === state.pageIndex || !observer) {
if (!observer) state.visibleThumbnails.add(index);
renderThumbnail(index, openSeq);
}
}
function thumbnailObserver() {
if (!("IntersectionObserver" in window)) {
return null;
}
if (!state.thumbnailObserver) {
const openSeq = state.openSeq;
state.thumbnailObserver = new IntersectionObserver((entries) => {
if (openSeq !== state.openSeq) {
return;
}
for (const entry of entries) {
const index = Number.parseInt(entry.target.dataset.pageIndex, 10);
if (state.thumbnailWindow && state.thumbnailWindow.nodes.get(index) !== entry.target) continue;
if (!entry.isIntersecting) {
state.visibleThumbnails.delete(index);
const thumb = entry.target.querySelector(".thumb-paper");
if (thumb.dataset.renderKey) {
setThumbnailContent(thumb, "", index, openSeq);
}
continue;
}
state.visibleThumbnails.add(index);
renderThumbnail(index, openSeq);
}
trimPageCache();
}, {
root: el.navigationContent,
rootMargin: "180px 0px",
});
}
return state.thumbnailObserver;
}
async function renderThumbnail(index, openSeq = state.openSeq) {
if (openSeq !== state.openSeq || !state.doc) {
return;
}
if (state.pageCache.has(index)) {
updateThumbnail(index, openSeq);
return;
}
const key = `${openSeq}:${index}`;
if (state.thumbnailInFlight.has(key)) {
return;
}
state.thumbnailInFlight.add(key);
try {
const page = await loadPageData(index, { openSeq, priority: 5 });
if (openSeq !== state.openSeq) {
return;
}
if (!page) {
return;
}
updateThumbnail(index, openSeq);
trimPageCache();
} catch {
if (openSeq === state.openSeq) {
markThumbnailError(index);
}
} finally {
state.thumbnailInFlight.delete(key);
}
}
function updateThumbnail(index, openSeq = state.openSeq) {
if (openSeq !== state.openSeq || (state.thumbnailObserver && !state.visibleThumbnails.has(index) && index !== state.pageIndex)) {
return;
}
const thumb = el.pageList.querySelector(`[data-page-index="${index}"] .thumb-paper`);
if (thumb) {
setThumbnailContent(thumb, state.pageCache.get(index)?.svg, index, openSeq);
}
}
function markThumbnailError(index) {
const thumb = el.pageList.querySelector(`[data-page-index="${index}"] .thumb-paper`);
if (thumb) {
delete thumb.dataset.renderKey;
thumb.classList.add("error");
thumb.textContent = String(index + 1);
}
}
function renderMeta(keepDetails = false) {
renderSecurity();
el.conversionNotice.hidden = !state.conversionWarnings.length;
renderMetaContent(el.conversionWarnings, state.conversionWarnings, () => {
el.conversionWarnings.replaceChildren(...state.conversionWarnings.map(warning => {
const item = document.createElement("p");
item.textContent = `第${warning.Page}页 · 位置${warning.Offset}:${warning.Message}`;
return item;
}));
});
const doc = state.doc || {};
updateDocumentControls();
renderMetaContent(el.documentSelect, doc.documents || [], () => {
el.documentSelect.replaceChildren(...(doc.documents || []).map(entry => {
const option = document.createElement("option");
option.value = String(entry.index);
option.textContent = `${entry.index + 1} · ${entry.title || "未命名"}`;
return option;
}));
});
el.documentSelect.value = String(doc.documentIndex || 0);
el.metaPanel.setAttribute("aria-busy", String(!!doc.detailsPending));
document.title = `OFDGo WebUI - ${state.fileName}`;
el.metaFile.textContent = state.fileName;
el.metaTitle.textContent = doc.title || "-";
el.metaAuthor.textContent = doc.author || "-";
for (const [field, value] of [
[el.metaSubject, (doc.subject || "").trim()],
[el.metaCreationDate, formatDocumentTime(doc.creationDate)],
[el.metaModDate, formatDocumentTime(doc.modDate)],
[el.metaCreator, [doc.creator, doc.creatorVersion].filter(Boolean).join(" ")],
]) {
field.textContent = value;
field.parentElement.hidden = !value;
}
el.metaType.textContent = doc.docType || "-";
el.metaVersion.textContent = doc.version || "-";
el.metaFonts.textContent = String(doc.fontCount || 0);
el.pageTotal.textContent = String(doc.pageCount || 0);
if (keepDetails && doc.detailsPending) return;
el.metaSignatures.textContent = doc.detailsPending ? "正在检查签名" : doc.detailsError ? "读取失败" : String(doc.signatureCount || 0);
renderMetaContent(el.signatureList, [doc.signatures, doc.signatureError], renderSignatures);
updateStructureControls();
renderMetaContent(el.docFontList, doc.fonts || [], renderDocumentFonts);
refreshEditorFonts();
updateLocalFontButton();
}
function renderMetaContent(node, value, render) {
const key = JSON.stringify(value);
if (metaContents.get(node) === key) return;
render();
metaContents.set(node, key);
}
function openStructureDialog() {
if (!state.editing || !state.doc || document.body.hasAttribute("aria-busy")) return;
el.structureEntry.replaceChildren();
for (const [index, structure] of (state.doc.structures || []).entries()) {
el.structureEntry.add(new Option(`${index + 1} · ${structure.root?.name || structure.typeId}`, index));
}
el.structureEntry.add(new Option("新建标引", -1));
el.structureAction.value = "update";
refreshStructureNodes();
el.structureDialog.showModal();
}
function refreshStructureNodes() {
const structure = state.doc.structures?.[Number(el.structureEntry.value)];
el.structureNodeSelect.replaceChildren();
const append = (node, path, depth) => {
if (node.references?.length) return;
el.structureNodeSelect.add(new Option(`${" ".repeat(depth)}${node.name}`, JSON.stringify(path)));
for (const [index, child] of (node.children || []).entries()) append(child, [...path, index], depth + 1);
};
if (structure?.root) append(structure.root, [], 0);
refreshStructureFields();
}
function refreshStructureFields() {
const index = Number(el.structureEntry.value), creating = index < 0;
const structure = state.doc.structures?.[index];
let node = structure?.root, canBind = Boolean(structure?.canBind);
for (const part of JSON.parse(el.structureNodeSelect.value || "[]")) {
node = node?.children?.[part];
canBind &&= node?.namespace === structure.root.namespace;
}
canBind &&= node?.name !== "ObjectRef";
el.structureAction.querySelector('[value="bind"]').disabled = !canBind;
if (!canBind && el.structureAction.value === "bind") el.structureAction.value = "update";
if (!creating && !node) el.structureAction.value = "delete";
for (const option of el.structureAction.options) option.disabled = option.value === "bind" ? !canBind : !creating && !node && option.value !== "delete";
const action = creating ? "create" : el.structureAction.value;
document.querySelector("#structureNodeRow").hidden = creating || !node;
document.querySelector("#structureActionRow").hidden = creating;
document.querySelector("#structureTypeRow").hidden = !creating;
el.structureType.value = creating ? "custom" : structure?.typeId || "";
el.structureFields.hidden = action === "bind" || action === "delete";
el.structureBinding.hidden = action !== "bind";
el.structureName.value = action === "update" ? node?.name || "" : "";
el.structureNamespace.value = creating ? "" : node?.namespace || "";
el.structureText.value = action === "update" ? node?.text || "" : "";
el.structureAttributes.replaceChildren();
el.structureReferences.replaceChildren();
if (action === "update") for (const attr of node?.attributes || []) addStructureAttribute(attr);
if (action === "bind") {
const references = [...(node?.references || []), ...(node?.children || []).flatMap(child => child.references || [])];
for (const ref of references) addStructureReference(ref);
if (!references.length) addStructureReference();
}
for (const control of el.structureFields.querySelectorAll("input,textarea,button")) control.disabled = el.structureFields.hidden;
el.structureType.disabled = !creating;
el.structureStatus.textContent = structure?.error || (action === "delete" ? "删除此节点及子节点" : "");
el.structureSubmit.textContent = ({ create: "新增", add: "新增", update: "修改", bind: "绑定", delete: "删除" })[action];
}
function addStructureAttribute(attribute = {}) {
const row = document.createElement("div");
row.className = "structure-attribute";
for (const [name, label] of [["name", "属性名称"], ["namespace", "命名空间"], ["value", "属性内容"]]) {
const input = document.createElement("input");
input.type = "text";
input.name = name;
input.value = attribute[name] || "";
input.placeholder = label;
input.setAttribute("aria-label", label);
input.required = name === "name";
if (name === "value") input.className = "structure-attribute-value";
row.append(input);
}
const remove = document.createElement("button");
remove.type = "button";
remove.className = "icon-button";
remove.textContent = "×";
remove.title = "删除属性";
remove.setAttribute("aria-label", remove.title);
remove.addEventListener("click", () => row.remove());
row.append(remove);
el.structureAttributes.append(row);
}
function addStructureReference(reference = {}) {
const row = document.createElement("div");
row.className = "structure-reference";
const page = document.createElement("select"), object = document.createElement("select"), ids = document.createElement("input");
ids.type = "text";
page.name = "page";
ids.name = "ids";
ids.value = (reference.objectIds || []).join(",");
for (const [index, item] of state.doc.pages.entries()) page.add(new Option(`第${index + 1}页`, item.id));
if (reference.pageId) page.value = reference.pageId;
const remove = document.createElement("button"), add = document.createElement("button");
for (const [button, label, text] of [[remove, "删除引用", "×"], [add, "添加图元", "+"]]) {
button.type = "button";
button.className = "icon-button";
button.textContent = text;
button.title = label;
button.setAttribute("aria-label", label);
}
remove.addEventListener("click", () => row.remove());
add.addEventListener("click", () => {
if (object.value) ids.value = [...ids.value.split(/[,\s]+/u).filter(Boolean), object.value].join(",");
});
for (const [label, control, button] of [["页码", page, remove], ["图元", object, add], ["顺序", ids, null]]) {
const line = document.createElement("div"), title = document.createElement("label"), unit = document.createElement("div");
line.className = "form-row";
title.textContent = label;
control.setAttribute("aria-label", label);
unit.className = "form-unit";
unit.append(control);
if (button) unit.append(button);
line.append(title, unit);
row.append(line);
}
el.structureReferences.append(row);
const load = async () => {
const value = page.value, sequence = state.openSeq;
object.replaceChildren(new Option("读取中", ""));
add.disabled = true;
try {
const index = state.doc.pages.findIndex(item => item.id === value);
const targets = await callWASM("ofdgoStructureObjects", index);
if (!row.isConnected || sequence !== state.openSeq || value !== page.value) return;
object.replaceChildren(new Option("选择图元", ""));
for (const target of targets || []) object.add(new Option(`${target.id} · ${(target.text || "图元").slice(0, 60)}`, target.id));
add.disabled = false;
} catch (err) {
if (!row.isConnected || sequence !== state.openSeq || value !== page.value) return;
object.replaceChildren(new Option("读取失败", ""));
el.structureStatus.textContent = err.message;
}
};
page.addEventListener("change", () => { ids.value = ""; load(); });
load();
}
function renderStructures() {
const structures = state.doc?.structures || [];
el.structurePanel.hidden = !structures.length && !state.doc?.structureError && !(state.doc && state.editing);
el.structureManage.hidden = !state.editing;
el.structureSummary.textContent = structures.length;
el.structureList.replaceChildren();
if (state.doc?.structureError) appendInfoLine(el.structureList, "错误", state.doc.structureError, "fail");
for (const [index, structure] of structures.entries()) {
if (structure.root) el.structureList.append(structureNode(structure.root, index, []));
if (structure.error) appendInfoLine(el.structureList, "错误", structure.error, "fail");
}
}
function updateStructureControls() {
renderMetaContent(el.structureList, [state.doc?.structures, state.doc?.structureError, state.doc?.documentIndex, state.doc?.documentVersion, state.openSeq, state.editorInfo?.revision, state.editing], renderStructures);
el.structureManage.disabled = !state.ready || state.exporting || document.body.hasAttribute("aria-busy") || Boolean(state.doc?.detailsPending);
}
function structureNode(node, index, path) {
const details = document.createElement("details");
details.className = "structure-node";
const summary = document.createElement("summary");
summary.textContent = node.name;
details.append(summary);
let loaded = false;
details.addEventListener("toggle", async () => {
if (!details.open || loaded) return;
loaded = true;
const content = document.createElement("div");
content.className = "structure-content";
details.append(content);
for (const attr of node.attributes || []) appendInfoLine(content, attr.name, attr.value);
if (node.text?.trim() && !node.references?.length) appendInfoLine(content, "原文", node.text);
const children = node.children || [];
const references = node.references?.length || children.some(child => child.references?.length);
for (const [i, child] of children.entries()) {
if (!child.references?.length) content.append(structureNode(child, index, [...path, i]));
}
if (!references) return;
const pending = document.createElement("div");
pending.className = "panel-detail";
pending.textContent = "读取中";
pending.setAttribute("role", "status");
content.append(pending);
const sequence = state.openSeq;
try {
const targets = await callWASM("ofdgoStructureTargets", index, path);
if (sequence !== state.openSeq || !details.isConnected) return;
pending.remove();
appendInfoLine(content, "原文", targets.map(target => target.text || "").join(""));
const pages = new Map();
for (const target of targets) {
if (target.error) { appendInfoLine(content, "引用错误", `${target.pageId} · ${target.id}:${target.error}`, "fail"); continue; }
const b = target.bounds;
if (!(b.W > 0 && b.H > 0)) continue;
const region = pages.get(target.page);
if (region) {
const right = Math.max(region.x + region.width, b.X + b.W), bottom = Math.max(region.y + region.height, b.Y + b.H);
region.x = Math.min(region.x, b.X); region.y = Math.min(region.y, b.Y);
region.width = right - region.x; region.height = bottom - region.y;
} else pages.set(target.page, { page: target.page, x: b.X, y: b.Y, width: b.W, height: b.H });
}
if (pages.size) {
const line = document.createElement("div");
line.className = "info-line";
const label = document.createElement("span");
label.className = "info-label"; label.textContent = "位置";
const links = document.createElement("span"); links.className = "info-value structure-links";
for (const region of pages.values()) {
const button = document.createElement("button");
button.type = "button"; button.className = "signature-stamp-link";
button.textContent = `第${region.page}页`; button.title = "定位原文";
button.addEventListener("click", () => focusPageRegion(region, node.name));
links.append(button);
}
line.append(label, links); content.append(line);
}
} catch (err) {
pending.textContent = "读取失败";
appendInfoLine(content, "错误", err.message, "fail");
loaded = false;
details.addEventListener("toggle", () => { if (!details.open) content.remove(); }, { once: true });
}
});
return details;
}
function renderSignatures() {
const signatures = state.doc?.signatures || [];
const showPanel = Boolean(state.doc?.signatureError || signatures.length);
el.signaturePanel.hidden = !showPanel;
el.signatureList.replaceChildren();
el.signatureSummary.textContent = signatureSummary(signatures);
if (!showPanel) {
return;
}
if (state.doc?.signatureError) {
const empty = document.createElement("div");
empty.className = "font-empty";
empty.textContent = state.doc.signatureError;
el.signatureList.append(empty);
return;
}
const fragment = document.createDocumentFragment();
for (const signature of signatures) {
const row = document.createElement("div");
row.className = `signature-row ${signature.status}`;
const head = document.createElement("div");
head.className = "signature-head";
const name = signatureNameNode(signature);
const badges = document.createElement("div");
badges.className = "signature-badges";
badges.append(fontBadge(signature.trustedValid ? "可信" : signature.status === "valid" ? "完整" : signature.status === "invalid" ? "异常" : "未验", signature.status));
head.append(badges, name);
row.append(head);
appendInfoLine(row, "签署人", signature.signer);
appendInfoLine(row, "时间", formatDocumentTime(signature.signatureDateTime));
appendInfoLine(row, "章名", signature.sealName);
appendInfoLine(row, "机构", signatureAgency(signature));
appendSignatureCheck(row, "信任", signature.certTrustChecked, signature.certTrustOK);
appendInfoLine(row, "保护文件", signatureReferenceText(signature), signatureReferenceStatus(signature));
appendInfoLine(row, "错误", signature.error, "fail");
const checks = document.createElement("details");
const checksTitle = document.createElement("summary");
checksTitle.textContent = "校验详情";
checks.append(checksTitle);
appendSignatureCheck(checks, "原文", signature.dataHashChecked, signature.dataHashOK);
appendSignatureCheck(checks, "签名", signature.signedValueChecked, signature.signedValueOK);
appendSignatureCheck(checks, "证书", signature.certChecked, signature.certOK);
appendSignaturePolicy(checks, "证书期限", signature.certTimeChecked, signature.certTimeOK);
appendInfoLine(checks, "信任错误", signature.certTrustError, "fail");
appendSignaturePolicy(checks, "签署时间", signature.signatureTimeChecked, signature.signatureTimeOK);
if (signature.type !== "Sign") {
appendSignatureCheck(checks, "印章", signature.sealChecked, signature.sealOK);
appendSignaturePolicy(checks, "印章匹配", signature.sealMatchChecked, signature.sealMatchOK);
appendSignaturePolicy(checks, "印章期限", signature.sealTimeChecked, signature.sealTimeOK);
if (signature.sealCertTimeChecked) {
appendInfoLine(checks, "制章证书", signature.sealCertTimeOK ? "有效" : "失效", signature.sealCertTimeOK ? "ok" : "fail");
}
}
appendSignatureCheck(checks, "覆盖", signature.coverageChecked, signature.coverageOK);
appendInfoLine(checks, "覆盖错误", signature.coverageError, "fail");
appendInfoLine(checks, "未保护", signature.uncoveredFiles?.join("、"), "fail");
appendSignatureCheck(checks, "策略", signature.policyChecked, signature.policyOK);
appendInfoLine(checks, "策略错误", signature.policyError, "fail");
appendSignatureCheck(checks, "时间戳", signature.timestampChecked, signature.timestampOK);
appendSignatureCheck(checks, "撤销", signature.revocationChecked, signature.revocationOK);
row.append(checks);
for (const timestamp of signature.timestamps || []) {
const details = document.createElement("details");
const title = document.createElement("summary");
title.textContent = timestamp.valid ? "可信时间戳" : "时间戳详情";
details.append(title);
appendInfoLine(details, timestamp.valid ? "时间" : "声明时间", formatDocumentTime(timestamp.time));
appendSignatureCheck(details, "绑定", timestamp.bindingChecked, timestamp.bindingOK);
appendSignatureCheck(details, "签名", timestamp.signedValueChecked, timestamp.signedValueOK);
appendSignatureCheck(details, "信任", timestamp.certTrustChecked, timestamp.certTrustOK);
appendSignatureCheck(details, "时效", timestamp.certTimeChecked, timestamp.certTimeOK);
appendInfoLine(details, "错误", timestamp.error, "fail");
row.append(details);
}
for (const revocation of signature.revocations || []) {
const details = document.createElement("details");
const title = document.createElement("summary");
title.textContent = "撤销详情";
details.append(title);
appendInfoLine(details, "证书", revocation.subject);
appendInfoLine(details, "来源", revocation.source);
const valid = revocation.checked && revocation.ok && revocation.status === "good";
appendInfoLine(details, "状态", !revocation.checked ? "未验" : valid ? "未撤销" : revocation.status === "revoked" ? "已撤销" : "未知", valid ? "ok" : revocation.checked ? "fail" : "");
appendInfoLine(details, "更新", formatDocumentTime(revocation.thisUpdate));
appendInfoLine(details, "截止", formatDocumentTime(revocation.nextUpdate));
appendInfoLine(details, "撤销时间", formatDocumentTime(revocation.revokedAt));
appendInfoLine(details, "错误", revocation.error, "fail");
row.append(details);
}
const details = document.createElement("details");
const title = document.createElement("summary");
title.textContent = "签章信息";
details.append(title);
appendInfoLine(details, "编号", signature.id);
appendInfoLine(details, "文档", signature.docRoot);
appendInfoLine(details, "算法", signature.signatureMethod);
appendInfoLine(details, "摘要", signature.digestMethod);
appendInfoLine(details, "证书主体", signature.signSubject);
appendInfoLine(details, "颁发者", signature.signIssuer);
appendInfoLine(details, "序列号", signature.signSerial);
appendInfoLine(details, "章号", signature.sealId);
appendInfoLine(details, "章图", signature.sealType);
appendInfoLine(details, "制章证书", signature.sealSubject);
appendInfoLine(details, "印章厂商", signature.sealVendor);
appendInfoLine(details, "组件版本", signature.version);
row.append(details);
fragment.append(row);
}
el.signatureList.append(fragment);
}
function signatureSummary(signatures) {
if (!signatures.length) {
return "0";
}
const invalid = signatures.filter((signature) => signature.status === "invalid").length;
const unchecked = signatures.filter((signature) => signature.status === "unchecked").length;
const parts = [String(signatures.length)];
if (invalid) {
parts.push(`异常 ${invalid}`);
}
if (unchecked) {
parts.push(`未验 ${unchecked}`);
}
return parts.length > 1 ? parts.join(" · ") : `${signatures.every(signature => signature.trustedValid) ? "可信" : "完整"} ${signatures.length}`;
}
function signatureNameNode(signature) {
const stamps = signature.docIndex ? [] : signature.stamps || [];
const name = document.createElement("div");
name.className = "signature-name";
if (!stamps.length) {
name.textContent = "签名";
return name;
}
const button = document.createElement("button");
button.type = "button";
button.className = "info-name-button";
button.textContent = "签名";
button.addEventListener("click", () => focusPageRegion(stamps[0], "签名"));
name.append(button, signatureStampGroup(stamps));
return name;
}
function signatureStampGroup(stamps) {
const group = document.createElement("span");
group.className = "signature-stamp-group";
for (const [index, stamp] of stamps.entries()) {
const item = document.createElement("span");
item.className = "signature-stamp-item";
const button = document.createElement("button");
button.type = "button";
button.className = "signature-stamp-link";
button.textContent = `第${index + 1}处`;
button.addEventListener("click", () => focusPageRegion(stamp, "签名"));
item.append(index === 0 ? "(" : "", button, index === stamps.length - 1 ? ")" : "、");
group.append(item);
}
return group;
}
function signatureAgency(signature) {
if (signature.company && signature.provider) {
return `${signature.company} · ${signature.provider}`;
}
return signature.company || signature.provider || "";
}
function signatureReferenceText(signature) {
const passed = signature.referencePassed || 0;
const count = signature.referenceCount || 0;
if (!count) {
return "";
}
const status = signatureReferenceStatus(signature);
return `${status === "ok" ? "通过" : status === "fail" ? "失败" : "未验"} ${passed} / ${count}`;
}
function signatureReferenceStatus(signature) {
const count = signature.referenceCount || 0;
if (!count) {
return "";
}
if (signature.referencePassed < signature.referenceChecked) {
return "fail";
}
return signature.referenceChecked === count ? "ok" : "";
}
function formatDocumentTime(value) {
const text = String(value || "").trim().replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/, "$1-$2-$3 $4:$5:$6$7$8").replace("T", " ");
return /^0001[-/]0?1[-/]0?1(?: 0{1,2}:00:00(?:\.0+)?(?:Z|[+-]00:?00)?)?$/.test(text) ? "" : text;
}
function appendInfoLine(row, label, value, status = "") {
if (!value && value !== 0) {
return;
}
const line = document.createElement("div");
line.className = status ? `info-line ${status}` : "info-line";
const key = document.createElement("span");
key.className = "info-label";
key.textContent = label;
const text = document.createElement("span");
text.className = "info-value";
text.textContent = String(value);
line.append(key, text);
row.append(line);
}
function appendSignatureCheck(row, label, checked, ok) {
appendInfoLine(row, label, checked ? ok ? "通过" : "失败" : "未验", checked ? ok ? "ok" : "fail" : "");
}
function appendSignaturePolicy(row, label, checked, ok) {
if (!checked) {
return;
}
appendSignatureCheck(row, label, checked, ok);
}
async function focusPageRegion(region, label, alignment = "nearest") {
const pageIndex = region.page - 1;
if (!state.doc || pageIndex < 0 || document.body.hasAttribute("aria-busy")) {
return;
}
const openSeq = state.openSeq;
if (!await renderPage(pageIndex, { fit: false, scroll: false })) {
return;
}
await nextFrame();
if (openSeq !== state.openSeq || pageIndex !== state.pageIndex) {
return;
}
highlightPageRegion(region, alignment);
setStatus(`已定位${label}(第${region.page}页)`);
}
function highlightPageRegion(region, alignment = "nearest") {
clearRegionHighlights();
const pageIndex = region.page - 1;
const shell = pageShell(pageIndex);
if (!shell || !(region.width > 0 && region.height > 0)) {
return;
}
const mark = document.createElement("div");
mark.className = "region-highlight";
mark.style.left = `${region.x * MM_TO_PX}px`;
mark.style.top = `${region.y * MM_TO_PX}px`;
mark.style.width = `${region.width * MM_TO_PX}px`;
mark.style.height = `${region.height * MM_TO_PX}px`;
shell.querySelector(".page-surface").append(mark);
if (state.pageWindow) {
const box = mark.getBoundingClientRect(), viewer = el.viewerPanel.getBoundingClientRect();
if (alignment === "center") scrollViewerBy(box.top - viewer.top + (box.height - el.viewerPanel.clientHeight) / 2);
else if (box.top < viewer.top) scrollViewerBy(box.top - viewer.top);
else if (box.bottom > viewer.top + el.viewerPanel.clientHeight) scrollViewerBy(Math.min(box.top - viewer.top, box.bottom - viewer.top - el.viewerPanel.clientHeight));
if (box.left < viewer.left) el.viewerPanel.scrollLeft += box.left - viewer.left;
else if (box.right > viewer.left + el.viewerPanel.clientWidth) el.viewerPanel.scrollLeft += box.right - viewer.left - el.viewerPanel.clientWidth;
} else mark.scrollIntoView({ block: alignment, inline: "nearest" });
window.setTimeout(() => mark.remove(), 1800);
}
function clearRegionHighlights() {
for (const mark of el.svgHost.querySelectorAll(".region-highlight")) {
mark.remove();
}
}
function mountAnnotationNotes(index, surface, annotations) {
for (const note of surface.querySelectorAll(".annotation-note")) note.remove();
if (!state.renderAnnotations) return;
const page = state.doc.pages[index];
for (const annotation of annotations || []) {
if (!annotation.visible || annotation.page !== index + 1 || !annotation.remark?.trim()) continue;
const note = document.createElement("button");
note.type = "button";
note.className = "annotation-note";
note.classList.toggle("no-zoom", !!annotation.noZoom);
note.classList.toggle("no-rotate", !!annotation.noRotate);
note.textContent = "\u24d8";
note.title = "查看注解";
note.setAttribute("aria-label", "查看注解");
note.setAttribute("aria-haspopup", "dialog");
note.style.left = `${Math.max(22 / MM_TO_PX, Math.min(page.width, annotation.x + annotation.width)) * MM_TO_PX}px`;
note.style.top = `${Math.max(0, Math.min(page.height - 22 / MM_TO_PX, annotation.y)) * MM_TO_PX}px`;
note.addEventListener("click", () => {
if (state.editing || document.body.hasAttribute("aria-busy")) return;
el.annotationDetail.replaceChildren();
el.annotationFields.hidden = el.annotationSubmit.hidden = true;
el.annotationDetail.hidden = false;
el.annotationStatus.textContent = "";
el.annotationClose.textContent = "关闭";
const content = document.createElement("div");
content.textContent = annotation.remark;
el.annotationDetail.append(content);
appendInfoLine(el.annotationDetail, "作者", annotation.creator);
appendInfoLine(el.annotationDetail, "时间", formatDocumentTime(annotation.lastModDate));
el.annotationNote.showModal();
});
surface.append(note);
}
}
function renderDocumentFonts() {
const fonts = state.doc?.fonts || [];
el.docFontList.replaceChildren();
el.docFontSummary.textContent = fontSummary(fonts);
if (!fonts.length) {
const empty = document.createElement("div");
empty.className = "font-empty";
empty.textContent = "暂无字体";
el.docFontList.append(empty);
return;
}
const fragment = document.createDocumentFragment();
for (const font of fonts) {
const row = document.createElement("div");
row.className = `doc-font-row ${font.status || "missing"}`;
const head = document.createElement("div");
head.className = "doc-font-head";
const name = document.createElement("div");
name.className = "doc-font-name";
name.textContent = font.fontName || font.familyName || font.id || "-";
const badges = document.createElement("div");
badges.className = "doc-font-badges";
badges.append(fontBadge(statusText(font.status), font.status || "missing"));
if (font.embedded && font.status !== "embedded") {
badges.append(fontBadge("内嵌", "embedded"));
}
const resultText = fontResult(font);
const metaText = fontMeta(font);
head.append(badges, name);
if (state.editing) {
const action = document.createElement("button");
action.type = "button";
action.className = "small-button font-binding-action";
action.textContent = font.embedded ? "外置" : "嵌入";
action.title = font.embedded ? "外置字体" : "嵌入字体";
action.setAttribute("aria-label", action.title);
action.addEventListener("click", () => openFontBinding(font));
head.append(action);
}
row.append(head);
appendFontDetail(row, metaText);
appendFontDetail(row, resultText);
fragment.append(row);
}
el.docFontList.append(fragment);
}
async function openFontBinding(font) {
if (!state.editing || document.body.hasAttribute("aria-busy")) return;
if (!font.embedded && font.status === "matched") {
await changeDocument("ofdgoChangeFontBinding", null, font.id, 1);
return;
}
fontBinding.font = font;
fontBinding.sequence++;
fontBindingElements.Form.inert = false;
fontBinding.choices = fontManager.editorFonts().filter(choice => !choice.disabled);
fontBindingElements.Title.textContent = font.embedded ? "外置字体" : "嵌入字体";
fontBindingElements.Name.textContent = font.fontName || font.familyName || font.id;
fontBindingElements.SourceRow.hidden = font.embedded;
fontBindingElements.Source.disabled = font.embedded;
fontBindingElements.Source.replaceChildren(new Option("请选择", ""), ...fontBinding.choices.map((choice, i) => new Option(choice.fullName || choice.name, String(i))), new Option("添加字体", "file"));
fontBindingElements.Hint.hidden = !font.embedded;
fontBindingElements.Status.textContent = "";
fontBindingElements.Submit.textContent = font.embedded ? "外置" : "嵌入";
fontBindingElements.Panel.showModal();
}
function fontBindingError(message) {
if (/requires the original font/.test(message)) return "字形编号需匹配原字体";
if (/non-character glyph|private character|index-encoded/.test(message)) return "字形映射无法外置";
if (/unsupported content/.test(message)) return "扩展引用无法确认";
if (/does not permit/.test(message)) return "字体不允许嵌入";
if (/no glyph|glyph is unavailable/.test(message)) return "字体缺少所需字形";
if (/unavailable|not found|no name/.test(message)) return "匹配字体不可用";
return message;
}
function appendFontDetail(row, text) {
if (!text) {
return;
}
const detail = document.createElement("div");
detail.className = "doc-font-detail";
detail.textContent = text;
detail.title = text;
row.append(detail);
}
function fontBadge(text, status) {
const badge = document.createElement("span");
badge.className = `font-badge ${status}`;
badge.textContent = text;
return badge;
}
function fontSummary(fonts) {
if (!fonts.length) {
return "0";
}
const missing = fonts.filter((font) => font.status === "missing").length;
const fallback = fonts.filter((font) => font.status === "fallback").length;
if (missing || fallback) {
return `${fonts.length} · 缺失 ${missing} · 回退 ${fallback}`;
}
return `共 ${fonts.length}`;
}
function pageStatus(index, pageCount) {
return `第 ${index + 1} / ${pageCount} 页`;
}
function statusText(status) {
switch (status) {
case "pending":
return "待查";
case "embedded":
return "内嵌";
case "matched":
return "匹配";
case "fallback":
return "回退";
default:
return "缺失";
}
}
function fontResult(font) {
const parts = [];
if (font.matched) {
parts.push(font.embedded || font.status === "embedded" ? `内嵌 ${font.matched}` : `匹配 ${font.matched}`);
}
if (font.matchedFace?.fullName) {
parts.push(font.matchedFace.fullName);
if (font.matchedFace.index > 0) parts.push(`索引 ${font.matchedFace.index}`);
}
if (font.detail) {
parts.push(font.detail);
}
return parts.join(" · ");
}
function fontMeta(font) {
const parts = [];
if (font.familyName && font.familyName !== font.fontName) {
parts.push(`字族 ${font.familyName}`);
}
if (font.charset) {
parts.push(`字集 ${font.charset}`);
}
return parts.join(" · ");
}
function setContinuous(continuous) {
state.continuous = continuous;
el.pageFrame.classList.toggle("continuous", state.continuous);
el.continuousButton.setAttribute("aria-pressed", String(state.continuous));
}
function toggleContinuous() {
const anchor = state.fitMode === "free" ? scaleAnchor() : null;
setContinuous(!state.continuous);
if (state.pageWindow) {
updateFitSpace();
layoutPages();
}
clearRegionHighlights();
if (state.fitMode === "free") {
restoreScaleAnchor(anchor);
} else {
applyFit(false);
scrollToPage(state.pageIndex);
}
}
function rotatePages() {
if (document.body.hasAttribute("aria-busy")) {
return;
}
state.rotation = (state.rotation + 90) % 360;
clearRegionHighlights();
el.viewerPanel.classList.remove("single-page-fits-height");
layoutPages();
layoutThumbnails();
if (state.fitMode === "free") {
updateFitSpace();
} else {
applyFit(false);
}
scrollToPage(state.pageIndex);
}
function pageViewSize(page) {
return state.rotation % 180 ? { width: page.height, height: page.width } : page;
}
function togglePan() {
if (document.body.hasAttribute("aria-busy")) {
return;
}
setPan(!state.panMode);
}
function setPan(enabled) {
endPan();
state.panMode = enabled;
el.viewerPanel.classList.toggle("pan-mode", state.panMode);
el.panButton.setAttribute("aria-pressed", String(state.panMode));
updateEditorTools();
}
function startPan(event) {
if (!state.panMode || !state.doc || event.pointerType !== "mouse" || event.button !== 0 || document.body.hasAttribute("aria-busy") || event.target.closest("a, button")) {
return;
}
const rect = el.viewerPanel.getBoundingClientRect();
if (event.clientX >= rect.left + el.viewerPanel.clientWidth || event.clientY >= rect.top + el.viewerPanel.clientHeight) {
return;
}
event.preventDefault();
state.pan = { id: event.pointerId, x: event.clientX + el.viewerPanel.scrollLeft,
y: event.clientY + (state.pageWindow ? pageWindowTop(state.pageWindow) : el.viewerPanel.scrollTop) };
el.viewerPanel.setPointerCapture(event.pointerId);
el.viewerPanel.classList.add("panning");
canvasEditor.focus();
}
function movePan(event) {
const pan = state.pan;
if (!pan || event.pointerId !== pan.id) {
return;
}
el.viewerPanel.scrollLeft = pan.x - event.clientX;
if (state.pageWindow) syncPageWindow(state.pageWindow, pan.y - event.clientY);
else el.viewerPanel.scrollTop = pan.y - event.clientY;
}
function endPan(event) {
const pan = state.pan;
if (!pan || (event && event.pointerId !== pan.id)) {
return;
}
state.pan = null;
el.viewerPanel.classList.remove("panning");
if (el.viewerPanel.hasPointerCapture(pan.id)) {
el.viewerPanel.releasePointerCapture(pan.id);
}
}
function fitWidth(updateStatus = true) {
const page = currentPageInfo();
if (!page) {
return;
}
setScale(fitWidthScale(page), updateStatus, "width");
if (updateStatus) {
scrollToPage(state.pageIndex);
}
}
function fitWidthScale(page) {
page = pageViewSize(page);
const space = pageSpace();
const width = Math.max(1, page.width * MM_TO_PX);
let available = Math.max(1, el.viewerPanel.clientWidth - space * 2);
if (!viewerHasVerticalScrollbar()) {
const limit = Math.max(1, el.viewerPanel.clientHeight - space * 2);
let height = Math.max(1, page.height * MM_TO_PX) * (available / width);
if (state.continuous) {
height = 0;
for (const item of state.doc.pages) {
const size = pageViewSize(item);
height += available * size.height / size.width;
if (height > limit) break;
}
}
if (height > limit) {
available = Math.max(1, available - scrollbarWidth());
}
}
return Math.min(4, Math.max(0.2, available / width));
}
function fitHeight(updateStatus = true) {
const page = currentPageInfo();
if (!page) {
return;
}
const space = pageSpace();
let availableWidth = Math.max(1, el.viewerPanel.clientWidth - space * 2);
let availableHeight = Math.max(1, el.viewerPanel.clientHeight - space * 2);
const size = pageViewSize(page);
const width = Math.max(1, size.width * MM_TO_PX);
const height = Math.max(1, size.height * MM_TO_PX);
if (state.continuous) {
availableWidth = Math.max(1, el.viewerPanel.offsetWidth);
availableHeight = Math.max(1, el.viewerPanel.offsetHeight);
const contentWidth = state.doc.pages.reduce((max, item) => Math.max(max, pageViewSize(item).width), 0) * MM_TO_PX;
const contentHeight = state.doc.pages.reduce((total, item) => total + pageViewSize(item).height, 0) * MM_TO_PX;
const scale = Math.min(availableWidth / width, availableHeight / height);
const needsVerticalScrollbar = scale > availableHeight / contentHeight;
const needsHorizontalScrollbar = scale > availableWidth / contentWidth;
if (needsVerticalScrollbar || needsHorizontalScrollbar) {
const scrollbar = scrollbarWidth();
availableWidth = Math.max(1, availableWidth - (needsVerticalScrollbar ? scrollbar : 0));
availableHeight = Math.max(1, availableHeight - (needsHorizontalScrollbar ? scrollbar : 0));
const nextScale = Math.min(availableWidth / width, availableHeight / height);
if (!needsVerticalScrollbar && nextScale > availableHeight / contentHeight) {
availableWidth = Math.max(1, availableWidth - scrollbar);
}
if (!needsHorizontalScrollbar && nextScale > availableWidth / contentWidth) {
availableHeight = Math.max(1, availableHeight - scrollbar);
}
}
}
setScale(Math.min(availableWidth / width, availableHeight / height), updateStatus, "height");
if (updateStatus) {
scrollToPage(state.pageIndex);
}
}
function applyFit(updateStatus = true) {
if (state.fitMode === "height") {
fitHeight(updateStatus);
return;
}
if (state.fitMode === "width") {
fitWidth(updateStatus);
} else {
updateControls();
}
}
function setScale(nextScale, updateStatus = true, fitMode = "free") {
const anchor = updateStatus && fitMode === "free" ? scaleAnchor() : null;
const scale = Math.min(4, Math.max(0.2, nextScale));
const layoutChanged = scale !== state.scale || fitMode !== state.fitMode;
state.fitMode = fitMode;
state.scale = scale;
if (layoutChanged) {
clearRegionHighlights();
layoutPages();
}
updateFitSpace();
if (state.pageWindow && state.pageWindow.gap !== (Number.parseFloat(getComputedStyle(el.svgHost).rowGap) || 0)) layoutPages();
if (layoutChanged) {
restoreScaleAnchor(anchor);
}
el.zoomLabel.textContent = `${Math.round(state.scale * 100)}%`;
if (updateStatus && state.doc && !state.exporting) {
setStatus(`第 ${state.pageIndex + 1} / ${state.doc.pageCount} 页`);
}
updateControls();
}
function scaleAnchor(viewY = 0.45) {
const shell = pageShellFromView() || pageShell(state.pageIndex);
if (!shell) {
return null;
}
const viewerRect = el.viewerPanel.getBoundingClientRect();
const shellRect = shell.getBoundingClientRect();
return {
index: Number.parseInt(shell.dataset.pageIndex, 10),
viewY,
x: (viewerRect.left + viewerRect.width / 2 - shellRect.left) / Math.max(1, shellRect.width),
y: (viewerRect.top + viewerRect.height * viewY - shellRect.top) / Math.max(1, shellRect.height),
};
}
function restoreScaleAnchor(anchor) {
if (!anchor) {
return;
}
if (state.fitMode === "height" || (state.fitMode === "width" && anchor.y <= 0)) {
scrollToPage(anchor.index);
return;
}
if (state.pageWindow) {
const view = state.pageWindow;
const height = view.offsets[anchor.index + 1] - view.offsets[anchor.index] - view.gap;
syncPageWindow(view, view.offsets[anchor.index] + height * anchor.y - el.viewerPanel.clientHeight * anchor.viewY);
}
const shell = pageShell(anchor.index);
if (!shell) {
return;
}
const viewerRect = el.viewerPanel.getBoundingClientRect();
const shellRect = shell.getBoundingClientRect();
el.viewerPanel.scrollLeft += shellRect.left + shellRect.width * anchor.x - viewerRect.left - viewerRect.width / 2;
scrollViewerBy(shellRect.top + shellRect.height * anchor.y - viewerRect.top - viewerRect.height * anchor.viewY);
}
function currentPageInfo() {
return state.doc?.pages?.[state.pageIndex] || null;
}
function pageSpace() {
return Number.parseFloat(getComputedStyle(el.pageFrame).paddingLeft) || 0;
}
function pageBlockSpace() {
return Number.parseFloat(getComputedStyle(el.pageFrame).paddingTop) || pageSpace();
}
function viewerHasVerticalScrollbar() {
return el.viewerPanel.scrollHeight > el.viewerPanel.clientHeight;
}
function scrollbarWidth() {
const probe = document.createElement("div");
probe.style.position = "absolute";
probe.style.width = "100px";
probe.style.height = "100px";
probe.style.overflow = "scroll";
probe.style.scrollbarWidth = getComputedStyle(el.viewerPanel).scrollbarWidth;
probe.style.left = "-9999px";
document.body.append(probe);
const width = probe.offsetWidth - probe.clientWidth;
probe.remove();
return width;
}
function updateFitSpace() {
const page = currentPageInfo();
if (!page || state.fitMode === "free") {
el.viewerPanel.classList.remove("single-page-fits-height");
el.pageFrame.style.removeProperty("--fit-space");
el.pageFrame.style.removeProperty("--fit-gap");
return;
}
const shell = pageShell(state.pageIndex);
const height = shell ? shell.getBoundingClientRect().height : Math.max(1, pageViewSize(page).height * MM_TO_PX * state.scale);
const base = pageSpace();
el.viewerPanel.classList.toggle("single-page-fits-height", state.doc.pageCount === 1 && height <= el.viewerPanel.clientHeight - base * 2);
const space = state.continuous ? 0 : Math.max(base, (el.viewerPanel.clientHeight - height) / 2);
const gap = space > base ? space + 1 : space;
el.pageFrame.style.setProperty("--fit-space", `${space}px`);
el.pageFrame.style.setProperty("--fit-gap", `${gap}px`);
}
function updateDocumentControls() {
const count = state.doc?.documents?.length || 0, index = managedDocumentIndex(), manager = state.documentManagement;
const busy = document.body.hasAttribute("aria-busy");
const disabled = !state.doc || !state.ready || state.exporting || busy;
el.documentManageButton.hidden = !state.editing;
el.documentManageButton.disabled = disabled;
resourceElements.ManageButton.disabled = disabled;
settingsElements.Button.disabled = disabled;
updateResourceControls();
el.documentRow.hidden = !count || !state.editing && count < 2;
el.documentSelect.disabled = disabled;
const actionsDisabled = disabled || !state.editing || Boolean(manager?.loading || manager?.failed);
el.documentList.inert = busy || Boolean(manager?.loading);
el.documentAddButton.disabled = actionsDisabled;
el.infoButton.disabled = actionsDisabled || Boolean(manager && !manager.entries);
el.documentDeleteButton.disabled = actionsDisabled || count <= 1;
el.documentUpButton.disabled = actionsDisabled || index === 0;
el.documentDownButton.disabled = actionsDisabled || index >= count - 1;
updateVersionControls();
}
function updateControls() {
renderSecurity();
updateStructureControls();
const hasDoc = Boolean(state.doc);
updateDocumentControls();
updateDisplayControls();
updateEditorTools();
const pageCount = state.doc ? state.doc.pageCount : 0;
el.prevButton.disabled = !hasDoc || state.pageIndex <= 0;
el.nextButton.disabled = !hasDoc || state.pageIndex >= pageCount - 1;
el.pageInput.disabled = !hasDoc;
el.pageInput.max = String(pageCount || 1);
el.pageInput.value = String(hasDoc ? state.pageIndex + 1 : 0);
el.pageControl.style.setProperty("--page-width", `${String(pageCount || 1).length + 0.5}ch`);
el.zoomOutButton.disabled = !hasDoc || state.scale <= 0.2;
el.zoomInButton.disabled = !hasDoc || state.scale >= 4;
el.fitButton.disabled = !hasDoc;
el.fitHeightButton.disabled = !hasDoc;
el.rotateButton.disabled = !hasDoc;
el.panButton.disabled = !hasDoc;
el.continuousButton.disabled = !hasDoc;
el.fitButton.toggleAttribute("aria-pressed", hasDoc && state.fitMode === "width");
el.fitHeightButton.toggleAttribute("aria-pressed", hasDoc && state.fitMode === "height");
updateAnnotationButton();
el.exportFormat.disabled = !hasDoc || state.exporting || !state.exportFormats.length;
el.outputCompression.disabled = !hasDoc || state.exporting;
el.exportPageButton.disabled = !hasDoc || state.exporting || !state.exportFormats.length;
el.exportButton.disabled = !hasDoc || state.exporting || !state.exportFormats.length;
updateDPIControl();
}
function updateEditorTools() {
el.viewerPanel.classList.toggle("editing", state.editing);
el.editButton.disabled = !state.doc || !state.ready || state.exporting || document.body.hasAttribute("aria-busy");
el.editButton.setAttribute("aria-pressed", String(state.editing));
el.editButton.title = state.editing ? "阅读" : "编辑";
el.editButton.setAttribute("aria-label", el.editButton.title);
el.editNotice.textContent = state.editorInfo?.editWarnings?.join(";") || "";
el.editNotice.hidden = !state.editing || !el.editNotice.textContent;
el.editorTools.hidden = !state.editing;
const pagesDisabled = !state.editing || !state.ready || state.exporting;
el.insertTextButton.disabled = el.insertImageButton.disabled = pagesDisabled || !pageCan("insert");
el.saveButton.disabled = !state.doc || !state.ready || state.exporting || (!state.editorInfo && !state.ofdBytes);
el.addPageButton.disabled = pagesDisabled;
const selectedPages = state.editing ? selectedPageIndexes() : [state.pageIndex];
const selectionDisabled = pagesDisabled || !selectedPages.length;
el.batchPagesButton.disabled = selectionDisabled;
el.copyPageButton.disabled = selectionDisabled || selectedPages.some(index => !pageCan("copy", index));
el.pageSettingsButton.disabled = selectionDisabled || selectedPages.some(index => !pageCan("resize", index));
el.deletePageButton.disabled = selectionDisabled || selectedPages.some(index => !pageCan("delete", index)) || selectedPages.length >= state.doc?.pageCount;
const moveDisabled = selectionDisabled || selectedPages.some(index => !pageCan("move", index));
el.movePagePrevButton.disabled = moveDisabled || selectedPages[0] === 0;
el.movePageNextButton.disabled = moveDisabled || selectedPages.at(-1) === state.doc?.pageCount - 1;
const enabled = state.editing && state.selectObjects && !state.panMode && !sealPreview && !resourceManager.pick;
canvasEditor.setEnabled(enabled);
updateObjectControls(canvasEditor.selected);
el.undoButton.disabled = !state.editorInfo?.canUndo || !state.ready || state.exporting;
el.redoButton.disabled = !state.editorInfo?.canRedo || !state.ready || state.exporting;
}
function updateObjectControls(item, reset = false) {
el.compositeBackButton.hidden = !state.composite;
el.compositeBackButton.disabled = !state.ready || state.exporting;
el.selectObjectButton.hidden = Boolean(state.composite);
const members = item?.items || (item ? [item] : []);
const disabled = !item || Boolean(item.draft) || !state.ready || state.exporting;
el.deleteObjectButton.disabled = disabled || !canEditObject(item, "delete");
el.copyObjectButton.disabled = disabled || !canEditObject(item, "copy") || Boolean(state.composite && members.some(member => member.container !== members[0].container));
el.objectStyleButton.disabled = disabled || !canEditObject(item, "transform");
const cropping = Boolean(canvasEditor.crop);
el.objectBoundsButton.disabled = disabled || cropping || !canEditObject(item, "transform");
el.objectAlign.disabled = disabled || cropping || !canEditObject(item, "arrange");
el.objectRotate.disabled = el.objectFlip.disabled = disabled || cropping || !canEditObject(item, state.composite ? "transform" : "arrange");
el.cropImageButton.disabled = disabled || item.type !== "ImageObject" || !canEditObject(item, "cropImage");
el.saveImageButton.disabled = disabled || !item.imageResource;
el.imageFit.disabled = el.cropImageButton.disabled || cropping || Boolean(item.scoped && !canEditObject(item, "fitImage"));
el.cropImageButton.textContent = cropping ? "完成" : "裁剪";
el.cropImageButton.setAttribute("aria-label", cropping ? "完成裁剪" : "裁剪图片");
el.cropImageButton.setAttribute("aria-pressed", String(cropping));
el.resetCropButton.textContent = cropping ? "取消" : "还原";
el.resetCropButton.setAttribute("aria-label", cropping ? "取消裁剪" : "还原图片");
el.resetCropButton.disabled = canEditObject(item, "resetCrop") ? disabled : el.cropImageButton.disabled || !cropping && (item.scoped ? !item.cropped : !item.imageBounds || ["x", "y", "width", "height"].every(key => Math.abs(item[key] - item.imageBounds[key]) < 1e-9));
el.objectDistribute.disabled = el.objectAlign.disabled || !item.items || item.items.length < 3;
el.editObjectButton.disabled = disabled || Boolean(item.items) || !canEditObject(item, "enter") && (item.type === "PathObject"
|| !(item.type === "TextObject" && canEditObject(item, "rewriteText")) && !canEditObject(item, item.type === "TextObject" ? "textContent" : item.type === "ImageObject" ? "replaceImage" : "update"));
el.editObjectButton.textContent = canEditObject(item, "enter") ? "进入" : item?.type === "ImageObject" ? "替换" : "修改";
el.editObjectButton.title = canEditObject(item, "enter") ? item?.type === "Annotation" ? "进入注解" : "进入组合" : item?.type === "ImageObject" ? "替换图片" : item?.type === "Annotation" ? "修改注解" : "修改对象";
el.editObjectButton.setAttribute("aria-label", el.editObjectButton.title);
el.multiSelectButton.disabled = !state.editing || !canvasEditor.enabled || !state.ready || state.exporting;
const selection = selectedText(item);
const text = selection ? currentTextStyle() : state.textDefaults;
const textDisabled = !state.editing || !state.ready || state.exporting || Boolean(item?.items && !selection);
fontPicker.setDisabled(textDisabled || text !== state.textDefaults && !item.draft && !canEditObject(item, "replaceFont"));
el.textSize.disabled = textDisabled || text !== state.textDefaults && !item.draft && (!canEditObject(item, "reflow") || Boolean(item?.items) && !canEditObject(item, "layoutKnown"));
el.textColor.disabled = textDisabled || text !== state.textDefaults && !item.draft && !canEditObject(item, "paint");
el.textFontAdd.disabled = !state.editing || !state.ready || state.exporting;
el.textAlign.disabled = el.textWrap.disabled = el.textLineHeight.disabled = el.textSpacing.disabled = el.textSize.disabled || Boolean(item?.items || item?.draft) || Boolean(canvasEditor.input);
el.paragraphButton.disabled = el.textAlign.disabled;
const layoutKnown = text === state.textDefaults || Boolean(item?.draft) || canEditObject(item, "layoutKnown");
el.textAlign.value = layoutKnown ? text.align || "left" : "";
el.textLineHeight.placeholder = layoutKnown ? "自动" : "原文";
el.textSpacing.placeholder = layoutKnown ? "0" : "原文";
el.textWrap.setAttribute("aria-pressed", String(Boolean(text.wrap)));
if (reset || document.activeElement !== el.textLineHeight) {
el.textLineHeight.value = text.paragraphHeight ? String(displayPoints(text.paragraphHeight)) : "";
}
if (reset || document.activeElement !== el.textSpacing) {
el.textSpacing.value = text.letterSpacing ? String(displayPoints(text.letterSpacing)) : "";
}
updateTextFonts(item);
if (reset || el.textSize.disabled || document.activeElement !== el.textSize) {
el.textSize.value = text.size === undefined ? "" : displayPoints(text.size);
}
el.textSize.placeholder = text.size === undefined ? "混合" : "";
el.textSize.title = text.size === undefined ? "字号不同" : `字号${displayPoints(text.size)}pt`;
el.textColor.classList.toggle("mixed-color", Boolean(selection?.items && !text.color));
el.textColor.title = selection?.items && !text.color ? "文字颜色不同" : "文字颜色";
if (reset || el.textColor.disabled || document.activeElement !== el.textColor) {
el.textColor.value = text.color || "#000000";
}
const path = selectedPath(item);
for (const [input, key] of [[el.shapeFill, "fill"], [el.shapeStroke, "stroke"]]) {
input.indeterminate = Boolean(path?.items && path[key] === undefined);
if (path) input.checked = Boolean(path[key]);
}
for (const [input, key, label] of [[el.shapeFillColor, "fillColor", "填充颜色"], [el.shapeStrokeColor, "strokeColor", "描边颜色"]]) {
const mixed = Boolean(path?.items && path[key] === undefined);
input.classList.toggle("mixed-color", mixed);
input.title = mixed ? `${label}不同` : label;
if (path && (reset || document.activeElement !== input)) input.value = path[key] || "#000000";
}
el.shapeWidth.placeholder = path?.items && path.lineWidth === undefined ? "混合" : "";
if (path && canEditObject(path, "paint")) {
if (reset || document.activeElement !== el.shapeWidth) {
el.shapeWidth.value = path.lineWidth === undefined ? "" : displayPoints(path.lineWidth);
}
} else if (!item && !el.shapeWidth.value) {
el.shapeWidth.value = "1";
}
updateDrawingControls();
const orders = members.map(member => member.position).sort((a, b) => a - b);
const count = members[0]?.count || 0;
const canRaise = orders.some((order, i) => order !== count - orders.length + i);
const canLower = orders.some((order, i) => order !== i);
el.objectOrder.disabled = disabled || cropping || count <= orders.length || !canEditObject(item, "order")
|| members.some(member => member.container !== members[0].container);
for (const option of el.objectOrder.options) {
option.disabled = el.objectOrder.disabled || (["up", "top"].includes(option.value) ? !canRaise : !canLower);
}
}
function updateAnnotationButton() {
el.annotationButton.setAttribute("aria-pressed", String(state.renderAnnotations));
el.annotationButton.title = state.renderAnnotations ? "隐藏注解" : "显示注解";
el.annotationButton.setAttribute("aria-label", el.annotationButton.title);
}
async function callWASM(name, ...args) {
if (state.wasmExited) {
scheduleWASMRecovery();
throw new Error(state.wasmRecovering ? STATUS.recovering : "引擎运行中断");
}
if (!state.ready) {
throw new Error("引擎尚未就绪");
}
return new Promise((resolve, reject) => {
const id = ++wasmRequestID;
wasmRequests.set(id, { resolve, reject, openSeq: state.openSeq });
if (name === "ofdgoExportMedia") wasmRequests.get(id).mediaID = args[0];
if (name === "ofdgoConvertPDF") {
const request = wasmRequests.get(id);
request.localFonts = fontManager.localEntries();
request.fontData = new Map();
args.push([...request.localFonts.keys()]);
}
if (name === "ofdgoImportImages" || name === "ofdgoExportImages" || name === "ofdgoConvertPDF" || name === "ofdgoExportPage" || name === "ofdgoExportDocument" || name === "ofdgoExportAttachment" || name === "ofdgoExportResource" || name === "ofdgoExportVersion" || name === "ofdgoSaveDocument" || name === "ofdgoSaveSigned" || name === "ofdgoImportPages") {
state.exportRequestID = id;
el.cancelExportButton.hidden = false;
el.cancelExportButton.disabled = false;
el.cancelExportButton.title = { ofdgoImportImages: "取消导入", ofdgoConvertPDF: "取消转换", ofdgoSaveDocument: "取消保存", ofdgoSaveSigned: "取消签署", ofdgoImportPages: "取消导入", ofdgoExportAttachment: "取消下载" }[name] || "取消导出";
el.cancelExportButton.setAttribute("aria-label", el.cancelExportButton.title);
}
try {
const transfer = [];
if (name === "ofdgoOpen") {
args[0] = args[0].slice();
transfer.push(args[0].buffer);
}
if (name === "ofdgoLoadImport" && args[0]) transfer.push(args[0].buffer);
wasmWorker.postMessage({ id, name, args }, transfer);
} catch (err) {
wasmRequests.delete(id);
reject(err);
}
});
}
function showError(err, empty = !state.doc) {
const message = String(err.message || err);
setStatus(message);
if (empty) {
setEmpty(message);
}
}
function setBusy(busy, text = "", percent = 0, status = "") {
document.body.toggleAttribute("aria-busy", busy);
updateStructureControls();
updateDocumentControls();
renderSecurity();
updateDisplayControls();
el.editButton.disabled = busy || Boolean(sealPreview || resourceManager.pick) || !state.doc || !state.ready || state.exporting;
el.createForm.inert = busy && !state.imageImporting;
updateCreateOptions();
el.insertForm.inert = busy;
el.pageForm.inert = busy;
el.paragraphForm.inert = busy;
el.infoForm.inert = busy;
el.documentForm.inert = busy;
el.versionForm.inert = busy;
el.attachmentForm.inert = busy;
el.annotationForm.inert = busy;
el.importForm.inert = busy && !state.importing;
for (const input of [el.importFile, el.importRange, el.importPosition, el.importOutlines]) input.disabled = busy;
el.importPages.disabled = busy || el.importRange.value !== "custom";
el.importSubmit.disabled = busy || !state.importPageCount;
el.batchPagesForm.inert = busy;
el.objectStyleForm.inert = busy;
el.objectBoundsForm.inert = busy;
el.outlineForm.inert = busy;
el.structureForm.inert = busy;
el.outlineList.inert = busy;
el.navigationTabs.inert = busy;
el.pageList.inert = busy;
el.editorTools.inert = busy || Boolean(sealPreview || resourceManager.pick);
el.fontList.inert = busy;
el.docFontList.inert = busy;
if (!busy) {
el.progressPanel.hidden = true;
if (state.fontSyncPending) {
window.setTimeout(syncUserFonts, 0);
}
if (state.fontRenderPending) window.setTimeout(refreshPendingFonts, 0);
return;
}
el.exportPanel.close();
endPan();
el.progressPanel.hidden = !text;
setProgress(text, percent, status);
}
function setProgress(text = "", percent = 0, status = "") {
if (text) {
el.progressLabel.textContent = text;
}
if (status) {
setStatus(status);
}
el.progressBar.parentElement.hidden = percent === null;
if (percent === null) {
return;
}
const value = Math.max(0, Math.min(100, percent));
el.progressBar.style.width = `${value}%`;
el.progressBar.setAttribute("aria-valuenow", String(value));
}
function setStatus(text) {
el.statusText.textContent = text;
}
function setEmpty(text) {
el.emptyState.hidden = false;
el.pageFrame.hidden = true;
el.emptyState.textContent = text;
}
function formatSize(value) {
if (!Number.isFinite(value) || value <= 0) {
return "-";
}
return value.toFixed(1);
}
function downloadBytes(bytes, mime, name) {
const blob = new Blob([bytes], { type: mime });
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.href = url;
link.download = name;
document.body.append(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function baseFileName() {
const base = (state.fileName || state.doc?.title || "ofdgo").replace(/\.[^.]+$/, "");
return base.replace(/[\\/:*?"<>|]+/g, "_").trim() || "ofdgo";
}
function pageFileName(extension) {
const page = String(state.pageIndex + 1).padStart(String(state.doc?.pageCount || 1).length, "0");
return `${baseFileName()}_${page}.${extension}`;
}
function exportFormatInfo(value) {
return state.exportFormats.find((format) => format.value === value) || null;
}
function exportFormatUsesDPI(value) {
return value === "png" || value === "jpg";
}
function updateDPIControl() {
el.imageDPI.disabled = state.exporting || !state.doc || document.body.hasAttribute("aria-busy") || (displayMode() !== "raster" && !exportFormatUsesDPI(el.exportFormat.value));
el.imageDPI.title = `输出精度:${currentImageDPI()} DPI`;
el.dpiValue.textContent = String(currentImageDPI());
const format = exportFormatInfo(el.exportFormat.value);
el.formatValue.textContent = format?.label || "";
el.exportFormat.title = format ? `导出格式:${format.label}` : "导出格式";
}
function currentImageDPI() {
return Number.parseFloat(el.imageDPI.value) || DEFAULT_IMAGE_DPI;
}
function formatBytes(size) {
if (!Number.isFinite(size) || size <= 0) {
return "0 KB";
}
if (size < 1024 * 1024) {
return `${Math.round(size / 1024)} KB`;
}
return `${(size / 1024 / 1024).toFixed(1)} MB`;
}
function nextFrame() {
return new Promise((resolve) => requestAnimationFrame(resolve));
}
function waitForPaint() {
return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}