diff --git a/manifest-beta.json b/manifest-beta.json index 73ceed90..a05c2c7d 100644 --- a/manifest-beta.json +++ b/manifest-beta.json @@ -1,7 +1,7 @@ { "id": "pdf-plus", "name": "PDF++", - "version": "0.40.7", + "version": "0.40.8", "minAppVersion": "1.5.8", "description": "The most Obsidian-native PDF annotation tool ever.", "author": "Ryota Ushio", diff --git a/manifest.json b/manifest.json index 73ceed90..a05c2c7d 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "pdf-plus", "name": "PDF++", - "version": "0.40.7", + "version": "0.40.8", "minAppVersion": "1.5.8", "description": "The most Obsidian-native PDF annotation tool ever.", "author": "Ryota Ushio", diff --git a/package-lock.json b/package-lock.json index 4291da53..6c3531c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-pdf-plus", - "version": "0.40.7", + "version": "0.40.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-pdf-plus", - "version": "0.40.7", + "version": "0.40.8", "license": "MIT", "devDependencies": { "@cantoo/pdf-lib": "^1.21.0", diff --git a/package.json b/package.json index 17e598a3..060ce676 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-pdf-plus", - "version": "0.40.7", + "version": "0.40.8", "description": "The most Obsidian-native PDF annotation tool ever.", "scripts": { "dev": "node esbuild.config.mjs", diff --git a/src/bib.ts b/src/bib.ts index ed9f6776..7a61a75c 100644 --- a/src/bib.ts +++ b/src/bib.ts @@ -40,42 +40,33 @@ export class BibliographyManager extends PDFPlusComponent { isEnabled() { const viewer = this.child.pdfViewer; - return isNonEmbedLike(viewer) - || (this.settings.enableBibInCanvas && isCanvas(viewer)) - || (this.settings.enableBibInHoverPopover && isHoverPopover(viewer)) - || (this.settings.enableBibInEmbed && isEmbed(viewer)); + return this.settings.actionOnCitationHover !== 'none' + && ( + isNonEmbedLike(viewer) + || (this.settings.enableBibInCanvas && isCanvas(viewer)) + || (this.settings.enableBibInHoverPopover && isHoverPopover(viewer)) + || (this.settings.enableBibInEmbed && isEmbed(viewer)) + ); } private async init() { if (this.isEnabled()) { - await this.initBibText(); + await this.extractBibText(); await this.parseBibText(); } this.initialized = true; } - private async initBibText() { + private async extractBibText() { return new Promise((resolve) => { - this.lib.onDocumentReady(this.child.pdfViewer, async (doc) => { - const dests = await doc.getDestinations(); - const promises: Promise[] = []; - for (const destId in dests) { - if (destId.startsWith('cite.')) { - const destArray = dests[destId] as PDFJsDestArray; - promises.push( - BibliographyManager.getBibliographyTextFromDest(destArray, doc) - .then((bibInfo) => { - if (bibInfo) { - const bibText = bibInfo.text; - this.destIdToBibText.set(destId, bibText); - this.events.trigger('extracted', destId, bibText); - } - }) - ); - } - } - await Promise.all(promises); - resolve(); + this.lib.onDocumentReady(this.child.pdfViewer, (doc) => { + new BibliographyTextExtractor(doc) + .onExtracted((destId, bibText) => { + this.destIdToBibText.set(destId, bibText); + this.events.trigger('extracted', destId, bibText); + }) + .extract() + .then(resolve); }); }); } @@ -209,25 +200,74 @@ export class BibliographyManager extends PDFPlusComponent { return null; } - static async getBibliographyTextFromDest(dest: string | PDFJsDestArray, doc: PDFDocumentProxy) { - let explicitDest: PDFJsDestArray | null = null; - if (typeof dest === 'string') { - explicitDest = (await doc.getDestination(dest)) as PDFJsDestArray | null; - } else { - explicitDest = dest; + + on(name: 'extracted', callback: (destId: string, bibText: string) => any, ctx?: any): ReturnType; + on(name: 'parsed', callback: (destId: string, parsedBib: string) => any, ctx?: any): ReturnType; + on(...args: Parameters) { + return this.events.on(...args); + } +} + + +class BibliographyTextExtractor { + doc: PDFDocumentProxy; + pageRefToTextContentItemsPromise: Record | undefined>; + onExtractedCallback: (destId: string, bibText: string) => any; + + constructor(doc: PDFDocumentProxy) { + this.doc = doc; + this.pageRefToTextContentItemsPromise = {}; + } + + onExtracted(callback: BibliographyTextExtractor['onExtractedCallback']) { + this.onExtractedCallback = callback; + return this; + } + + async extract() { + const dests = await this.doc.getDestinations(); + const promises: Promise[] = []; + for (const destId in dests) { + if (destId.startsWith('cite.')) { + const destArray = dests[destId] as PDFJsDestArray; + promises.push( + this.extractBibTextForDest(destArray) + .then((bibInfo) => { + if (bibInfo) { + const bibText = bibInfo.text; + this.onExtractedCallback(destId, bibText); + } + }) + ); + } } - if (!explicitDest) return null; + await Promise.all(promises); + } - const pageNumber = await doc.getPageIndex(explicitDest[0]) + 1; - const page = await doc.getPage(pageNumber); - const items = (await page.getTextContent()).items as TextContentItem[]; + /** Get `TextContentItem`s contained in the specified page. This method avoids fetching the same info multiple times. */ + async getTextContentItemsFromPageRef(pageRef: PDFJsDestArray[0]) { + const refStr = JSON.stringify(pageRef); + + return this.pageRefToTextContentItemsPromise[refStr] ?? ( + this.pageRefToTextContentItemsPromise[refStr] = (async () => { + const pageNumber = await this.doc.getPageIndex(pageRef) + 1; + const page = await this.doc.getPage(pageNumber); + const items = (await page.getTextContent()).items as TextContentItem[]; + return items; + })() + ); + } + + async extractBibTextForDest(destArray: PDFJsDestArray) { + const pageRef = destArray[0]; + const items = await this.getTextContentItemsFromPageRef(pageRef); // Whole lotta hand-crafted rules LOL let beginIndex = -1; - if (explicitDest[1].name === 'XYZ') { - const left = explicitDest[2]; - const top = explicitDest[3]; + if (destArray[1].name === 'XYZ') { + const left = destArray[2]; + const top = destArray[3]; if (left === null || top === null) return null; beginIndex = items.findIndex((item: TextContentItem) => { if (!item.str) return false; @@ -235,8 +275,8 @@ export class BibliographyManager extends PDFPlusComponent { const itemTop = item.transform[5] + (item.height || item.transform[0]) * 0.8; return left <= itemLeft && itemTop <= top; }); - } else if (explicitDest[1].name === 'FitBH') { - const top = explicitDest[2]; + } else if (destArray[1].name === 'FitBH') { + const top = destArray[2]; if (top === null) return null; beginIndex = items.findIndex((item: TextContentItem) => { if (!item.str) return false; @@ -280,12 +320,6 @@ export class BibliographyManager extends PDFPlusComponent { return { text: toSingleLine(text), items: bibTextItems }; } - - on(name: 'extracted', callback: (destId: string, bibText: string) => any, ctx?: any): ReturnType; - on(name: 'parsed', callback: (destId: string, parsedBib: string) => any, ctx?: any): ReturnType; - on(...args: Parameters) { - return this.events.on(...args); - } } diff --git a/src/lib/commands.ts b/src/lib/commands.ts index c081292b..7d07f0a7 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -344,20 +344,27 @@ export class PDFPlusCommands extends PDFPlusLibSubmodule { } showOutline(checking: boolean) { - const sidebar = this.lib.getObsidianViewer(true)?.pdfSidebar; - if (sidebar) { - if (!sidebar.haveOutline) return false; - if (sidebar.isOpen && sidebar.active === 2) { - if (this.settings.closeSidebarWithShowCommandIfExist) { - if (!checking) sidebar.close(); - return true; + const pdfViewer = this.lib.getObsidianViewer(true); + if (!pdfViewer) return false; + + const el = pdfViewer.dom?.containerEl; + + if (!pdfViewer.isEmbed || (el && el.contains(el.doc.activeElement))) { + const sidebar = pdfViewer?.pdfSidebar; + if (sidebar) { + if (!sidebar.haveOutline) return false; + if (sidebar.isOpen && sidebar.active === 2) { + if (this.settings.closeSidebarWithShowCommandIfExist) { + if (!checking) sidebar.close(); + return true; + } + return false; } - return false; - } - if (!checking) { - sidebar.switchView(SidebarView.OUTLINE, true); + if (!checking) { + sidebar.switchView(SidebarView.OUTLINE, true); + } + return true; } - return true; } if (this.settings.executeBuiltinCommandForOutline) { @@ -408,11 +415,16 @@ export class PDFPlusCommands extends PDFPlusLibSubmodule { zoom(checking: boolean, zoomIn: boolean) { const pdfViewer = this.lib.getObsidianViewer(true); - if (pdfViewer) { - if (!checking) { - zoomIn ? pdfViewer.zoomIn() : pdfViewer.zoomOut(); + if (!pdfViewer) return false; + + const el = pdfViewer.dom?.containerEl; + if (!pdfViewer.isEmbed || (el && el.contains(el.doc.activeElement))) { + if (pdfViewer) { + if (!checking) { + zoomIn ? pdfViewer.zoomIn() : pdfViewer.zoomOut(); + } + return true; } - return true; } if (this.settings.executeFontSizeAdjusterCommand) { diff --git a/src/lib/copy-link.ts b/src/lib/copy-link.ts index 5c985a60..cc4d5fd2 100644 --- a/src/lib/copy-link.ts +++ b/src/lib/copy-link.ts @@ -2,7 +2,7 @@ import { Editor, EditorRange, MarkdownFileInfo, MarkdownView, Notice, TFile } fr import { PDFPlusLibSubmodule } from './submodule'; import { PDFPlusTemplateProcessor } from 'template'; -import { encodeLinktext, getOffsetInTextLayerNode, getTextLayerNode, paramsToSubpath, parsePDFSubpath } from 'utils'; +import { encodeLinktext, getOffsetInTextLayerNode, getTextLayerNode, paramsToSubpath, parsePDFSubpath, subpathToParams } from 'utils'; import { Canvas, PDFOutlineTreeNode, PDFViewerChild, Rect } from 'typings'; import { ColorPalette } from 'color-palette'; @@ -93,14 +93,14 @@ export class copyLinkLib extends PDFPlusLibSubmodule { }; } - getLinkTemplateVariables(child: PDFViewerChild, displayTextFormat: string | undefined, file: TFile, subpath: string, page: number, text: string, sourcePath?: string) { + getLinkTemplateVariables(child: PDFViewerChild, displayTextFormat: string | undefined, file: TFile, subpath: string, page: number, text: string, comment: string, sourcePath?: string) { sourcePath = sourcePath ?? ''; const link = this.app.fileManager.generateMarkdownLink(file, sourcePath, subpath).slice(1); let linktext = this.app.metadataCache.fileToLinktext(file, sourcePath) + subpath; if (this.app.vault.getConfig('useMarkdownLinks')) { linktext = encodeLinktext(linktext); } - const display = this.getDisplayText(child, displayTextFormat, file, page, text); + const display = this.getDisplayText(child, displayTextFormat, file, page, text, comment); // https://github.com/obsidianmd/obsidian-api/issues/154 // const linkWithDisplay = app.fileManager.generateMarkdownLink(file, sourcePath, subpath, display).slice(1); const linkWithDisplay = this.lib.generateMarkdownLink(file, sourcePath, subpath, display || undefined).slice(1); @@ -120,7 +120,7 @@ export class copyLinkLib extends PDFPlusLibSubmodule { }; } - getDisplayText(child: PDFViewerChild, displayTextFormat: string | undefined, file: TFile, page: number, text: string) { + getDisplayText(child: PDFViewerChild, displayTextFormat: string | undefined, file: TFile, page: number, text: string, comment?: string) { if (!displayTextFormat) { // read display text format from color palette const palette = this.lib.getColorPaletteFromChild(child); @@ -137,7 +137,8 @@ export class copyLinkLib extends PDFPlusLibSubmodule { page, pageCount: child.pdfViewer.pagesCount, pageLabel: child.getPage(page).pageLabel ?? ('' + page), - text + text, + comment: comment ?? '', }).evalTemplate(displayTextFormat) .trim(); } catch (err) { @@ -149,15 +150,22 @@ export class copyLinkLib extends PDFPlusLibSubmodule { getTextToCopy(child: PDFViewerChild, template: string, displayTextFormat: string | undefined, file: TFile, page: number, subpath: string, text: string, colorName: string, sourcePath?: string) { const pageView = child.getPage(page); + // need refactor + const annotationId = subpathToParams(subpath).get('annotation'); + // @ts-ignore + let comment: string = (typeof annotationId === 'string' && pageView?.annotationLayer?.annotationLayer?.getAnnotation(annotationId)?.data?.contentsObj?.str) || ''; + comment = this.lib.toSingleLine(comment); + const processor = new PDFPlusTemplateProcessor(this.plugin, { file, page, pageLabel: pageView.pageLabel ?? ('' + page), pageCount: child.pdfViewer.pagesCount, text, + comment, colorName, calloutType: this.settings.calloutType, - ...this.lib.copyLink.getLinkTemplateVariables(child, displayTextFormat, file, subpath, page, text, sourcePath) + ...this.lib.copyLink.getLinkTemplateVariables(child, displayTextFormat, file, subpath, page, text, comment, sourcePath) }); const evaluated = processor.evalTemplate(template); diff --git a/src/lib/index.ts b/src/lib/index.ts index 17365fc7..91e788c9 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -18,6 +18,7 @@ import { AnnotationElement, CanvasFileNode, CanvasNode, CanvasView, DestArray, E import { PDFCroppedEmbed } from 'pdf-cropped-embed'; import { PDFBacklinkIndex } from './pdf-backlink-index'; import { Speech } from './speech'; +import * as utils from 'utils'; export class PDFPlusLib { @@ -38,6 +39,8 @@ export class PDFPlusLib { composer: PDFComposer; speech: Speech; + utils = utils; + constructor(plugin: PDFPlus) { this.app = plugin.app; this.plugin = plugin; diff --git a/src/main.ts b/src/main.ts index e7525495..855bcd9c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,12 +8,11 @@ import { ColorPalette } from 'color-palette'; import { DomManager } from 'dom-manager'; import { PDFCroppedEmbed } from 'pdf-cropped-embed'; import { DEFAULT_SETTINGS, PDFPlusSettings, PDFPlusSettingTab } from 'settings'; -import { subpathToParams, OverloadParameters, focusObsidian, isTargetHTMLElement, getInstallerVersion, isVersionOlderThan } from 'utils'; +import { subpathToParams, OverloadParameters, focusObsidian, isTargetHTMLElement } from 'utils'; import { DestArray, ObsidianViewer, PDFEmbed, PDFView, PDFViewerChild, PDFViewerComponent, Rect } from 'typings'; -import { ExternalPDFModal } from 'modals'; +import { ExternalPDFModal, InstallerVersionModal } from 'modals'; import { PDFExternalLinkPostProcessor, PDFInternalLinkPostProcessor, PDFOutlineItemPostProcessor, PDFThumbnailItemPostProcessor } from 'post-process'; import { BibliographyManager } from 'bib'; -import { InstallerVersionModal } from 'modals/installer-version-modal'; export default class PDFPlus extends Plugin { @@ -130,16 +129,13 @@ export default class PDFPlus extends Plugin { } } - private checkVersion() { + checkVersion() { const untestedVersion = '1.7.0'; if (requireApiVersion(untestedVersion)) { console.warn(`${this.manifest.name}: This plugin has not been tested on Obsidian ${untestedVersion} or above. Please report any issue you encounter on GitHub (https://github.com/RyotaUshio/obsidian-pdf-plus/issues/new/choose).`); } - const installerVersion = getInstallerVersion(); - if (installerVersion && isVersionOlderThan(installerVersion, '1.5.8')) { - new InstallerVersionModal(this).open(); - } + InstallerVersionModal.openIfNecessary(this); } private addIcons() { diff --git a/src/modals/index.ts b/src/modals/index.ts index 79eea7b8..5ced67b7 100644 --- a/src/modals/index.ts +++ b/src/modals/index.ts @@ -4,3 +4,4 @@ export * from './pdf-composer-modals'; export * from './outline-modals'; export * from './page-label-modals'; export * from './external-pdf-modals'; +export * from './installer-version-modal'; diff --git a/src/modals/installer-version-modal.ts b/src/modals/installer-version-modal.ts index eae8898f..1dc487c9 100644 --- a/src/modals/installer-version-modal.ts +++ b/src/modals/installer-version-modal.ts @@ -1,8 +1,18 @@ import { ButtonComponent } from 'obsidian'; + +import PDFPlus from 'main'; import { PDFPlusModal } from './base-modal'; +import { getInstallerVersion, isVersionOlderThan } from 'utils'; export class InstallerVersionModal extends PDFPlusModal { + static openIfNecessary(plugin: PDFPlus) { + const installerVersion = getInstallerVersion(); + if (installerVersion && isVersionOlderThan(installerVersion, plugin.manifest.minAppVersion)) { + new InstallerVersionModal(plugin).open(); + } + } + onOpen() { super.onOpen(); diff --git a/src/patchers/pdf-internals.ts b/src/patchers/pdf-internals.ts index 0ebfb281..41030313 100644 --- a/src/patchers/pdf-internals.ts +++ b/src/patchers/pdf-internals.ts @@ -11,7 +11,7 @@ import { patchPDFOutlineViewer } from 'patchers'; import { PDFViewerBacklinkVisualizer } from 'backlink-visualizer'; import { PDFPlusToolbar } from 'toolbar'; import { BibliographyManager } from 'bib'; -import { camelCaseToKebabCase, hookInternalLinkMouseEventHandlers, isModifierName, isNonEmbedLike, showChildElOnParentElHover } from 'utils'; +import { camelCaseToKebabCase, getCharactersWithBoundingBoxesInPDFCoords, hookInternalLinkMouseEventHandlers, isModifierName, isNonEmbedLike, showChildElOnParentElHover } from 'utils'; import { AnnotationElement, PDFOutlineViewer, PDFViewerComponent, PDFViewerChild, PDFSearchSettings, Rect, PDFAnnotationHighlight, PDFTextHighlight, PDFRectHighlight, ObsidianViewer, ObsidianServices, PDFPageView } from 'typings'; import { SidebarView, SpreadMode } from 'pdfjs-enums'; import { VimBindings } from 'vim/vim'; @@ -844,14 +844,58 @@ const patchPDFViewerChild = (plugin: PDFPlus, child: PDFViewerChild) => { } }, onThumbnailContextMenu(old) { - return function (evt: MouseEvent) { + return function (this: PDFViewerChild, evt: MouseEvent) { if (!plugin.settings.thumbnailContextMenu) { return old.call(this, evt); } onThumbnailContextMenu(plugin, this, evt); } - } + }, + getTextByRect(old) { + return function (this: PDFViewerChild, pageView: PDFPageView, rect: Rect) { + let text = ''; + + const items = pageView.textLayer?.textContentItems; + const divs = pageView.textLayer?.textDivs; + + if (items) { + const [left, bottom, right, top] = rect; + + for (let index = 0; index < items.length; index++) { + const item = items[index]; + + if (item.chars && item.chars.length) { + // This block is taken from app.js. + for (let offset = 0; offset < item.chars.length; offset++) { + const char = item.chars[offset]; + + const xMiddle = (char.r[0] + char.r[2]) / 2; + const yMiddle = (char.r[1] + char.r[3]) / 2; + + if (left <= xMiddle && xMiddle <= right && bottom <= yMiddle && yMiddle <= top) { + text += char.u; + } + } + } else if (divs && divs[index]) { + // This block is introduced by PDF++. + // If the text is not split into chars, we need to manually measure + // the bounding box of each character. + for (const { char, rect } of getCharactersWithBoundingBoxesInPDFCoords(pageView, divs[index])) { + const xMiddle = (rect[0] + rect[2]) / 2; + const yMiddle = (rect[1] + rect[3]) / 2; + + if (left <= xMiddle && xMiddle <= right && bottom <= yMiddle && yMiddle <= top) { + text += char; + } + } + } + } + } + + return text; + } + }, })); const onCopy = (evt: ClipboardEvent) => { diff --git a/src/settings.ts b/src/settings.ts index 900ff54f..5b50d033 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -4,7 +4,7 @@ import PDFPlus from 'main'; import { ExtendedPaneType } from 'lib/workspace-lib'; import { AutoFocusTarget } from 'lib/copy-link'; import { CommandSuggest, FuzzyFileSuggest, FuzzyFolderSuggest, FuzzyMarkdownFileSuggest, KeysOfType, getModifierDictInPlatform, getModifierNameInPlatform, isHexString } from 'utils'; -import { PAGE_LABEL_UPDATE_METHODS, PageLabelUpdateMethod } from 'modals'; +import { InstallerVersionModal, PAGE_LABEL_UPDATE_METHODS, PageLabelUpdateMethod } from 'modals'; import { ScrollMode, SidebarView, SpreadMode } from 'pdfjs-enums'; import { Menu } from 'obsidian'; import { PDFExternalLinkPostProcessor, PDFInternalLinkPostProcessor, PDFOutlineItemPostProcessor, PDFThumbnailItemPostProcessor } from 'post-process'; @@ -1318,7 +1318,7 @@ export class PDFPlusSettingTab extends PluginSettingTab { const categories = DEFAULT_SETTINGS[key]; const displayNames: Record = { 'color': 'Colors', - 'copy-format': 'Link copy format', + 'copy-format': 'Copy format', 'display': 'Display text format', }; const values = this.plugin.settings[key]; @@ -1437,6 +1437,13 @@ export class PDFPlusSettingTab extends PluginSettingTab { } async display(): Promise { + // First of all, re-display the installer version modal that was shown in plugin.onload again if necessary, + // in case the user has accidentally closed it. + InstallerVersionModal.openIfNecessary(this.plugin); + + + // Setting tab rendering starts here + this.headerContainerEl.empty(); this.contentEl.empty(); this.promises = []; @@ -1749,7 +1756,7 @@ export class PDFPlusSettingTab extends PluginSettingTab { } this.addToggleSetting('quietColorPaletteTooltip') .setName('Quiet tooltips in color palette') - .setDesc(`When disabled${!DEFAULT_SETTINGS.quietColorPaletteTooltip ? ' (default)' : ''}, the tooltip will show the color name as well as the selected link copy format and display text format. If enabled, only the color name will be shown.`); + .setDesc(`When disabled${!DEFAULT_SETTINGS.quietColorPaletteTooltip ? ' (default)' : ''}, the tooltip will show the color name as well as the selected copy format and display text format. If enabled, only the color name will be shown.`); } @@ -1936,7 +1943,7 @@ export class PDFPlusSettingTab extends PluginSettingTab { this.addProductMenuSetting('annotationProductMenuConfig', 'Copy link to annotation') this.addToggleSetting('updateColorPaletteStateFromContextMenu') .setName('Update color palette from context menu') - .setDesc('In the context menu, the items (color, link copy format and display text format) set in the color palette are selected by default. If this option is enabled, clicking a menu item will also update the color palette state and hence the default-selected items in the context menu as well.') + .setDesc('In the context menu, the items (color, copy format and display text format) set in the color palette are selected by default. If this option is enabled, clicking a menu item will also update the color palette state and hence the default-selected items in the context menu as well.') } @@ -1982,7 +1989,8 @@ export class PDFPlusSettingTab extends PluginSettingTab { '- **Fit width** / **fit height**', '- **Go to page**: This command brings the cursor to the page number input field in the PDF toolbar. Enter a page number and press Enter to jump to the page.', '- **Show copy format menu** / **show display text format menu**: By running thes commands via hotkeys and then using the arrow keys, you can quickly select a format from the menu without using the mouse.', - '- **Enable PDF edit** / **disable PDF edit**' + '- **Enable PDF edit** / **disable PDF edit**', + '- And more...', ], setting.descEl); }) .then((setting) => this.addHotkeySettingButton(setting)); @@ -2005,7 +2013,7 @@ export class PDFPlusSettingTab extends PluginSettingTab { }); - this.addHeading('Link copy templates', 'template', 'lucide-copy') + this.addHeading('Copy templates', 'template', 'lucide-copy') .setDesc('The template format that will be used when copying a link to a selection or an annotation in PDF viewer. ') this.addSetting() .then((setting) => this.renderMarkdown([ @@ -2019,7 +2027,8 @@ export class PDFPlusSettingTab extends PluginSettingTab { '- `pageLabel`: The page number displayed in the counter in the toolbar (`String`). This can be different from `page`.', ' - **Tip**: You can modify page labels with PDF++\'s "Edit page labels" command.', '- `pageCount`: The total number of pages (`Number`).', - '- `text` or `selection`: The selected text (`String`).', + '- `text` or `selection`: The selected text (`String`). In the case of links to annotations written directly in the PDF file, this is the text covered by the annotation.', + '- `comment`: In the case of links to annotations written directly in the PDF file, this is the comment associated with the annotation (`String`). Otherwise, it is an empty string `""`.', '- `folder`: The folder containing the PDF file ([`TFolder`](https://docs.obsidian.md/Reference/TypeScript+API/TFolder)). This is an alias for `file.parent`.', '- `obsidian`: The Obsidian API. See the [official developer documentation](https://docs.obsidian.md/Home) and the type definition file [`obsidian.d.ts`](https://github.com/obsidianmd/obsidian-api/blob/master/obsidian.d.ts) for the details.', '- `dv`: Available if the [Dataview](obsidian://show-plugin?id=dataview) plugin is enabled. See Dataview\'s [official documentation](https://blacksmithgu.github.io/obsidian-dataview/api/code-reference/) for the details. You can use it almost the same as the `dv` variable available in `dataviewjs` code blocks, but there are some differences. For example, `dv.current()` is not available.', @@ -2087,7 +2096,7 @@ export class PDFPlusSettingTab extends PluginSettingTab { } this.addSetting('copyCommands') - .setName('Custom link copy formats') + .setName('Custom copy formats') .then((setting) => this.renderMarkdown([ 'Customize the format to use when you copy a link by clicking a color palette item or running the commands while selecting a range of text in PDF viewer.', '', @@ -2543,7 +2552,7 @@ export class PDFPlusSettingTab extends PluginSettingTab { text.inputEl.size = 30; }); this.addTextAreaSetting('outlineLinkCopyFormat') - .setName('Link copy format') + .setName('Copy format') .then((setting) => { const textarea = setting.components[0] as TextAreaComponent; textarea.inputEl.rows = 3; @@ -2559,7 +2568,7 @@ export class PDFPlusSettingTab extends PluginSettingTab { text.inputEl.size = 30; }); this.addTextAreaSetting('copyOutlineAsListFormat') - .setName('List: link copy format') + .setName('List: copy format') .setDesc('You don\'t need to include leading hyphens in the template.') .then((setting) => { const textarea = setting.components[0] as TextAreaComponent; @@ -2573,7 +2582,7 @@ export class PDFPlusSettingTab extends PluginSettingTab { text.inputEl.size = 30; }); this.addTextAreaSetting('copyOutlineAsHeadingsFormat') - .setName('Headings: link copy format') + .setName('Headings: copy format') .setDesc('You don\'t need to include leading hashes in the template.') .then((setting) => { const textarea = setting.components[0] as TextAreaComponent; @@ -2627,7 +2636,7 @@ export class PDFPlusSettingTab extends PluginSettingTab { text.inputEl.size = 30; }); this.addTextAreaSetting('thumbnailLinkCopyFormat') - .setName('Link copy format') + .setName('Copy format') .then((setting) => { const textarea = setting.components[0] as TextAreaComponent; textarea.inputEl.rows = 3; diff --git a/src/utils/index.ts b/src/utils/index.ts index 15cd6709..1ca9cd4e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,7 +1,7 @@ import { Component, Modifier, Platform, CachedMetadata, ReferenceCache, parseLinktext, Menu, Scope, KeymapEventListener } from 'obsidian'; import { PDFDict, PDFName, PDFRef } from '@cantoo/pdf-lib'; -import { ObsidianViewer, PDFJsDestArray } from 'typings'; +import { ObsidianViewer, PDFJsDestArray, PDFPageView, Rect } from 'typings'; export * from './color'; export * from './suggest'; @@ -113,7 +113,47 @@ export function getNodeAndOffsetOfTextPos(node: Node, offset: number) { while ((textNode = iter.nextNode()) && offset >= textNode.textContent!.length) { offset -= textNode.textContent!.length; } - return textNode ? { node: textNode, offset } : null; + return textNode ? { node: textNode as Text, offset } : null; +} + +/** Generate the bounding box for each character in the given node. */ +export function* getCharacterBoundingBoxes(node: Node) { + const iter = node.doc.createNodeIterator(node, NodeFilter.SHOW_TEXT); + let textNode; + while (textNode = iter.nextNode()) { + if (textNode.instanceOf(Text)) { + for (let i = 0; i < textNode.length; i++) { + const range = textNode.doc.createRange(); + range.setStart(textNode, i); + range.setEnd(textNode, i + 1); + const rect = range.getBoundingClientRect(); + const char = textNode.textContent![i]; + yield { char, rect }; + } + } + } +} + +export function* toPDFCoords(pageView: PDFPageView, screenCoords: Iterable<{ x: number, y: number }>) { + const pageEl = pageView.div; + const style = pageEl.win.getComputedStyle(pageEl); + const borderTop = parseFloat(style.borderTopWidth); + const borderLeft = parseFloat(style.borderLeftWidth); + const paddingTop = parseFloat(style.paddingTop); + const paddingLeft = parseFloat(style.paddingLeft); + const pageRect = pageEl.getBoundingClientRect(); + + for (const { x, y } of screenCoords) { + const xRelativeToPage = x - (pageRect.left + borderLeft + paddingLeft) + const yRelativeToPage = y - (pageRect.top + borderTop + paddingTop); + yield pageView.getPagePoint(xRelativeToPage, yRelativeToPage) as [number, number]; + } +} + +export function* getCharactersWithBoundingBoxesInPDFCoords(pageView: PDFPageView, textLayerNode: HTMLElement) { + for (const { char, rect } of getCharacterBoundingBoxes(textLayerNode)) { + yield { char, rect: [...toPDFCoords(pageView, [{ x: rect.left, y: rect.bottom }, { x: rect.right, y: rect.top }])].flat() as Rect }; + } } export function getFirstTextNodeIn(node: Node): Text | null { @@ -178,10 +218,10 @@ export function isVersionOlderThan(a: string, b: string) { } export function getInstallerVersion(): string | null { - return Platform.isDesktopApp ? - // @ts-ignore - window.electron.remote.app.getVersion() : - null; + return Platform.isDesktopApp ? + // @ts-ignore + window.electron.remote.app.getVersion() : + null; } export function findReferenceCache(cache: CachedMetadata, start: number, end: number): ReferenceCache | undefined { @@ -262,7 +302,7 @@ export function isNonEmbedLike(pdfViewer: ObsidianViewer): boolean { /** This is a PDF embed in a markdown file (not a hover popover or a canvas card). */ export function isEmbed(pdfViewer: ObsidianViewer): boolean { - return pdfViewer.isEmbed && !this.isCanvas() && !isHoverPopover(pdfViewer); + return pdfViewer.isEmbed && !isCanvas(pdfViewer) && !isHoverPopover(pdfViewer); } export function isCanvas(pdfViewer: ObsidianViewer): boolean { @@ -342,7 +382,8 @@ export function toSingleLine(str: string, removeWhitespaceBetweenCJChars = false str = str.replace(/(.?)([\r\n]+)(.?)/g, (match, prev, br, next) => { if (cjRegexp.test(prev) && cjRegexp.test(next)) return prev + next; if (prev === '-' && next.match(/[a-zA-Z]/)) return next; - return prev + ' ' + next; + // Replace the line break with a whitespace if the line break is followed by a non-empty character. + return next ? prev + ' ' + next : prev; }); if (removeWhitespaceBetweenCJChars) { str = str.replace(new RegExp(`(${cjRegexp.source}) (?=${cjRegexp.source})`, 'g'), '$1'); diff --git a/src/vim/text-structure-parser.ts b/src/vim/text-structure-parser.ts index 5ce106df..57c357b5 100644 --- a/src/vim/text-structure-parser.ts +++ b/src/vim/text-structure-parser.ts @@ -2,7 +2,7 @@ import { TFile } from 'obsidian'; import PDFPlus from 'main'; import { PDFPlusComponent } from 'lib/component'; -import { areOverlapping, areOverlappingStrictly, binarySearch } from 'utils'; +import { areOverlapping, areOverlappingStrictly, binarySearch, getNodeAndOffsetOfTextPos, toPDFCoords } from 'utils'; import { PDFPageView, PDFViewer, TextContentItem } from 'typings'; @@ -199,31 +199,15 @@ export class PDFPageTextStructureParser { } const div = this.divs[itemIndex]; - const textNode = div.childNodes[0]; - if (textNode?.nodeType === Node.TEXT_NODE) { - const range = div.doc.createRange(); - range.setStart(textNode, charIndex); - range.setEnd(textNode, charIndex + 1); - const rect = range.getBoundingClientRect(); - - const pageEl = this.pageView.div; - const style = getComputedStyle(pageEl); - const borderTop = parseFloat(style.borderTopWidth); - const borderLeft = parseFloat(style.borderLeftWidth); - const paddingTop = parseFloat(style.paddingTop); - const paddingLeft = parseFloat(style.paddingLeft); - const pageRect = pageEl.getBoundingClientRect(); - - const left = rect.left - (pageRect.left + borderLeft + paddingLeft) - const top = rect.top - (pageRect.top + borderTop + paddingTop); - const right = rect.right - (pageRect.left + borderLeft + paddingLeft); - const bottom = rect.bottom - (pageRect.top + borderTop + paddingTop); - - const from = this.pageView.getPagePoint(left, top)[0]; - const to = this.pageView.getPagePoint(right, bottom)[0]; - return { from, to }; - } + const nodeAndOffset = getNodeAndOffsetOfTextPos(div, charIndex); + if (!nodeAndOffset) return null; + const { node: textNode, offset } = nodeAndOffset; + const range = div.doc.createRange(); + range.setStart(textNode, offset); + range.setEnd(textNode, offset + 1); + const rect = range.getBoundingClientRect(); + const [[from], [to]] = [...toPDFCoords(this.pageView, [{x: rect.left, y: rect.bottom}, {x: rect.right, y: rect.top}])]; - return null; + return { from, to }; } }