improved folder structure, add various settings, improve logging & code performance & file size

This commit is contained in:
Max Niclas Wächtler 2025-04-26 22:24:27 +02:00
parent ea1694a798
commit 3259a79552
6 changed files with 358 additions and 71 deletions

155
main.ts
View file

@ -1,4 +1,5 @@
import { NodeCanvasFactory } from "canvasFactory";
import { NodeCanvasFactory } from "utils/canvas/canvasFactory";
import { FolderSuggest } from "utils/suggest/folderSuggest";
import {
App,
Editor,
@ -11,35 +12,32 @@ import {
loadPdfJs,
} from "obsidian";
interface PDFPrinterSettings {
mySetting: string;
interface PdfPrinterSettings {
imageFolder: string;
imageQuality: number;
}
const DEFAULT_SETTINGS: PDFPrinterSettings = {
mySetting: "default",
const DEFAULT_SETTINGS: PdfPrinterSettings = {
imageFolder: "",
imageQuality: 0.5,
};
type PDFDocumentBuffer = {
type PdfDocumentBuffer = {
fileName: string;
pages: PDFPage[];
pages: PdfPage[];
};
type PDFPage = {
type PdfPage = {
pageNumber: number;
buffer: ArrayBuffer;
};
export default class MyPlugin extends Plugin {
settings: PDFPrinterSettings;
export default class PdfPrinterPlugin extends Plugin {
settings: PdfPrinterSettings;
async onload() {
console.log("loading plugin");
await this.loadSettings();
// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
const statusBarItemEl = this.addStatusBarItem();
statusBarItemEl.setText("Status Bar Text");
this.addCommand({
id: "convert-pdf-to-images",
name: "Convert PDF to images",
@ -49,27 +47,35 @@ export default class MyPlugin extends Plugin {
if (!pdfFile) {
return;
}
const fileBufferArray = await this.parsePDF(pdfFile);
const imagePathList = await this.convertPDFBufferToImages({
fileName: pdfFile.name,
new Notice(`Printing document '${pdfFile.name}'...`);
const fileBufferArray = await this.parsePdf(pdfFile);
const imagePathList = await this.convertPdfBufferToImages({
fileName: pdfFile.basename,
pages: fileBufferArray,
});
if (imagePathList.length === 0) {
new Notice(
"No images could be generated from the document."
);
return;
}
editor.replaceSelection(
imagePathList
.map((imagePath) => `![[${imagePath}]]`)
.join("\n")
);
new Notice(
`Document '${pdfFile.name}' was printed successfully.`
);
},
});
// This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new SampleSettingTab(this.app, this));
this.addSettingTab(new PdfPrinterSettingsTab(this.app, this));
}
onunload() {
console.log("unloading pdf printer plugin");
}
onunload() {}
async loadSettings() {
this.settings = Object.assign(
@ -97,16 +103,6 @@ export default class MyPlugin extends Plugin {
return null;
}
/**
* Takes the path of a file and returns the TFile object if it exists in the vault.
* If the file is not found, it returns null.
* @param path path to the file (either just the name or the full path)
* @returns File object if it exists, null otherwise
*/
private fetchVaultFile(path: string): TFile | null {
return this.app.metadataCache.getFirstLinkpathDest(path, "");
}
/**
* attempts to parse a string as a file path
* @param input the string to parse
@ -125,7 +121,7 @@ export default class MyPlugin extends Plugin {
);
return null;
}
const file = this.fetchVaultFile(filePath);
const file = this.app.metadataCache.getFirstLinkpathDest(filePath, "");
if (!file) {
new Notice(`File not found: ${filePath}.`);
return null;
@ -145,14 +141,14 @@ export default class MyPlugin extends Plugin {
* @throws Error if the canvas or context cannot be created.
* @returns An array of strings representing the paths of the saved PNG images.
*/
async parsePDF(file: TFile): Promise<PDFPage[]> {
async parsePdf(file: TFile): Promise<PdfPage[]> {
const pdfjs = await loadPdfJs();
const arrayBuffer = await this.app.vault.adapter.readBinary(file.path);
const buffer = new Uint8Array(arrayBuffer);
const document = await pdfjs.getDocument(buffer).promise;
const pages: number = document.numPages;
let pdfBuffer: PDFPage[] = [];
let pdfBuffer: PdfPage[] = [];
for (let i = 1; i <= pages; i++) {
const page = await document.getPage(i);
@ -164,23 +160,32 @@ export default class MyPlugin extends Plugin {
false
);
if (!canvas || !context) {
console.error("could not generate canvas or context");
console.error(
"pdf-printer: could not generate canvas or context"
);
return [];
}
await page.render({ canvasContext: context, viewport }).promise;
const blob = await new Promise<Blob | null>((resolve) => {
/** @ts-ignore because toBlob exists, but is not found */
canvas.toBlob(resolve, "image/png");
canvas.toBlob(
resolve,
"image/webp",
this.settings.imageQuality
);
});
if (blob) {
pdfBuffer.push({
pageNumber: i,
buffer: await blob.arrayBuffer(), // Store base64 string (binary data)
buffer: await blob.arrayBuffer(),
});
} else {
console.error(`could not generate blob for page ${i}`);
console.error(
`pdf-printer: could not generate blob for page ${i}`
);
}
canvasFactory.destroy({ canvas, context });
}
return pdfBuffer;
@ -192,42 +197,74 @@ export default class MyPlugin extends Plugin {
* @param pageNumber The page number of the PDF.
* @returns The path of the saved PNG file.
*/
private async convertPDFBufferToImages(
pdfBuffer: PDFDocumentBuffer
private async convertPdfBufferToImages(
pdfBuffer: PdfDocumentBuffer
): Promise<string[]> {
let uuid = crypto.randomUUID();
while (
await this.app.vault.adapter.exists(
`${this.settings.imageFolder}/${uuid}`
)
) {
// if folder already exists, generate a new uuid
uuid = crypto.randomUUID();
}
await this.app.vault.createFolder(
`${this.settings.imageFolder}/${uuid}`
);
const writeTasks = pdfBuffer.pages.map(async (page) => {
const name = `${pdfBuffer.fileName}-${page.pageNumber}.png`;
await this.app.vault.createBinary(name, page.buffer);
return name;
const fileName = `${pdfBuffer.fileName}-${page.pageNumber}.webp`;
const fullPath = `${this.settings.imageFolder}/${uuid}/${fileName}`;
await this.app.vault.createBinary(fullPath, page.buffer);
return fileName;
});
return Promise.all(writeTasks);
}
}
class SampleSettingTab extends PluginSettingTab {
plugin: MyPlugin;
class PdfPrinterSettingsTab extends PluginSettingTab {
plugin: PdfPrinterPlugin;
constructor(app: App, plugin: MyPlugin) {
constructor(app: App, plugin: PdfPrinterPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName("Setting #1")
.setDesc("It's a secret")
.addText((text) =>
text
.setPlaceholder("Enter your secret")
.setValue(this.plugin.settings.mySetting)
.setName("Image quality")
.setDesc(
"Quality of the printed images. Higher values result in better quality but larger file sizes."
)
.addSlider((cb) => {
cb.setLimits(0, 1, 0.01)
.setValue(this.plugin.settings.imageQuality)
.setDynamicTooltip()
.onChange(async (value) => {
this.plugin.settings.mySetting = value;
this.plugin.settings.imageQuality = value;
await this.plugin.saveSettings();
})
);
});
});
new Setting(this.containerEl)
.setName("Printed image folder path")
.setDesc("Place printed images from PDFs in this folder.")
.addSearch((cb) => {
new FolderSuggest(this.app, cb.inputEl);
cb.setPlaceholder("Example: folder1/folder2")
.setValue(this.plugin.settings.imageFolder)
.onChange(async (new_folder) => {
new_folder = new_folder.trim();
new_folder = new_folder.replace(/\/$/, "");
this.plugin.settings.imageFolder = new_folder;
await this.plugin.saveSettings();
});
/** @ts-ignore */
cb.containerEl.addClass("templater_search");
});
}
}

32
package-lock.json generated
View file

@ -1,13 +1,17 @@
{
"name": "obsidian-sample-plugin",
"version": "1.0.0",
"name": "obsidian-pdf-printer",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-sample-plugin",
"version": "1.0.0",
"name": "obsidian-pdf-printer",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@popperjs/core": "^2.11.8",
"canvas": "^3.1.0"
},
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
@ -184,6 +188,15 @@
"node": ">= 8"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@types/codemirror": {
"version": "5.60.8",
"dev": true,
@ -688,9 +701,9 @@
"peer": true
},
"node_modules/detect-libc": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
"integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
"engines": {
"node": ">=8"
}
@ -1594,11 +1607,6 @@
"node": ">=8"
}
},
"node_modules/pdf2img-electron": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/pdf2img-electron/-/pdf2img-electron-1.2.3.tgz",
"integrity": "sha512-KXgIjhrRH3ItOIYwF1zBMx8C1xcJ3tCF9RKChm/8vq7SGZH1nZL6jR0E/sz0qMvneXs65fK6jK3HU3UikKD0SQ=="
},
"node_modules/picomatch": {
"version": "2.3.1",
"dev": true,

View file

@ -20,5 +20,9 @@
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "4.7.4"
},
"dependencies": {
"@popperjs/core": "^2.11.8",
"canvas": "^3.1.0"
}
}

View file

@ -0,0 +1,37 @@
// Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes
import { App, TAbstractFile, TFolder } from "obsidian";
import { TextInputSuggest } from "utils/suggest/suggest";
export class FolderSuggest extends TextInputSuggest<TFolder> {
constructor(app: App, inputEl: HTMLInputElement | HTMLTextAreaElement) {
super(app, inputEl);
}
getSuggestions(inputStr: string): TFolder[] {
const abstractFiles = this.app.vault.getAllLoadedFiles();
const folders: TFolder[] = [];
const lowerCaseInputStr = inputStr.toLowerCase();
abstractFiles.forEach((folder: TAbstractFile) => {
if (
folder instanceof TFolder &&
folder.path.toLowerCase().contains(lowerCaseInputStr)
) {
folders.push(folder);
}
});
return folders.slice(0, 1000);
}
renderSuggestion(file: TFolder, el: HTMLElement): void {
el.setText(file.path);
}
selectSuggestion(file: TFolder): void {
this.inputEl.value = file.path;
this.inputEl.trigger("input");
this.close();
}
}

201
utils/suggest/suggest.ts Normal file
View file

@ -0,0 +1,201 @@
// Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes
import { App, ISuggestOwner, Scope } from "obsidian";
import { createPopper, Instance as PopperInstance } from "@popperjs/core";
const wrapAround = (value: number, size: number): number => {
return ((value % size) + size) % size;
};
class Suggest<T> {
private owner: ISuggestOwner<T>;
private values: T[];
private suggestions: HTMLDivElement[];
private selectedItem: number;
private containerEl: HTMLElement;
constructor(
owner: ISuggestOwner<T>,
containerEl: HTMLElement,
scope: Scope
) {
this.owner = owner;
this.containerEl = containerEl;
containerEl.on(
"click",
".suggestion-item",
this.onSuggestionClick.bind(this)
);
containerEl.on(
"mousemove",
".suggestion-item",
this.onSuggestionMouseover.bind(this)
);
scope.register([], "ArrowUp", (event) => {
if (!event.isComposing) {
this.setSelectedItem(this.selectedItem - 1, true);
return false;
}
});
scope.register([], "ArrowDown", (event) => {
if (!event.isComposing) {
this.setSelectedItem(this.selectedItem + 1, true);
return false;
}
});
scope.register([], "Enter", (event) => {
if (!event.isComposing) {
this.useSelectedItem(event);
return false;
}
});
}
onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void {
event.preventDefault();
const item = this.suggestions.indexOf(el);
this.setSelectedItem(item, false);
this.useSelectedItem(event);
}
onSuggestionMouseover(_event: MouseEvent, el: HTMLDivElement): void {
const item = this.suggestions.indexOf(el);
this.setSelectedItem(item, false);
}
setSuggestions(values: T[]) {
this.containerEl.empty();
const suggestionEls: HTMLDivElement[] = [];
values.forEach((value) => {
const suggestionEl = this.containerEl.createDiv("suggestion-item");
this.owner.renderSuggestion(value, suggestionEl);
suggestionEls.push(suggestionEl);
});
this.values = values;
this.suggestions = suggestionEls;
this.setSelectedItem(0, false);
}
useSelectedItem(event: MouseEvent | KeyboardEvent) {
const currentValue = this.values[this.selectedItem];
if (currentValue) {
this.owner.selectSuggestion(currentValue, event);
}
}
setSelectedItem(selectedIndex: number, scrollIntoView: boolean) {
const normalizedIndex = wrapAround(
selectedIndex,
this.suggestions.length
);
const prevSelectedSuggestion = this.suggestions[this.selectedItem];
const selectedSuggestion = this.suggestions[normalizedIndex];
prevSelectedSuggestion?.removeClass("is-selected");
selectedSuggestion?.addClass("is-selected");
this.selectedItem = normalizedIndex;
if (scrollIntoView) {
selectedSuggestion.scrollIntoView(false);
}
}
}
export abstract class TextInputSuggest<T> implements ISuggestOwner<T> {
protected app: App;
protected inputEl: HTMLInputElement | HTMLTextAreaElement;
private popper: PopperInstance;
private scope: Scope;
private suggestEl: HTMLElement;
private suggest: Suggest<T>;
constructor(app: App, inputEl: HTMLInputElement | HTMLTextAreaElement) {
this.app = app;
this.inputEl = inputEl;
this.scope = new Scope();
this.suggestEl = createDiv("suggestion-container");
const suggestion = this.suggestEl.createDiv("suggestion");
this.suggest = new Suggest(this, suggestion, this.scope);
this.scope.register([], "Escape", this.close.bind(this));
this.inputEl.addEventListener("input", this.onInputChanged.bind(this));
this.inputEl.addEventListener("focus", this.onInputChanged.bind(this));
this.inputEl.addEventListener("blur", this.close.bind(this));
this.suggestEl.on(
"mousedown",
".suggestion-container",
(event: MouseEvent) => {
event.preventDefault();
}
);
}
onInputChanged(): void {
const inputStr = this.inputEl.value;
const suggestions = this.getSuggestions(inputStr);
if (!suggestions) {
this.close();
return;
}
if (suggestions.length > 0) {
this.suggest.setSuggestions(suggestions);
this.open(this.app.workspace.containerEl, this.inputEl);
} else {
this.close();
}
}
open(container: HTMLElement, inputEl: HTMLElement): void {
this.app.keymap.pushScope(this.scope);
container.appendChild(this.suggestEl);
this.popper = createPopper(inputEl, this.suggestEl, {
placement: "bottom-start",
modifiers: [
{
name: "sameWidth",
enabled: true,
fn: ({ state, instance }) => {
// Note: positioning needs to be calculated twice -
// first pass - positioning it according to the width of the popper
// second pass - position it with the width bound to the reference element
// we need to early exit to avoid an infinite loop
const targetWidth = `${state.rects.reference.width}px`;
if (state.styles.popper.width === targetWidth) {
return;
}
state.styles.popper.width = targetWidth;
instance.update();
},
phase: "beforeWrite",
requires: ["computeStyles"],
},
],
});
}
close(): void {
this.app.keymap.popScope(this.scope);
this.suggest.setSuggestions([]);
if (this.popper) this.popper.destroy();
this.suggestEl.detach();
}
abstract getSuggestions(inputStr: string): T[];
abstract renderSuggestion(item: T, el: HTMLElement): void;
abstract selectSuggestion(item: T): void;
}