Skip to content

Commit

Permalink
Desktop: Resolves #9794: Plugin API: Add support for loading PDFs wit…
Browse files Browse the repository at this point in the history
…h the imaging API (#10177)
  • Loading branch information
personalizedrefrigerator committed Mar 27, 2024
1 parent 06c7c13 commit 06aa640
Show file tree
Hide file tree
Showing 8 changed files with 303 additions and 75 deletions.
Original file line number Diff line number Diff line change
@@ -1,12 +1,35 @@
import { Rectangle } from './types';
export interface Implementation {
nativeImage: any;
}
export interface CreateFromBufferOptions {
width?: number;
height?: number;
scaleFactor?: number;
}
export interface CreateFromPdfOptions {
/**
* The first page to export. Defaults to `1`, the first page in
* the document.
*/
minPage?: number;
/**
* The number of the last page to convert. Defaults to the last page
* if not given.
*
* If `maxPage` is greater than the number of pages in the PDF, all pages
* in the PDF will be converted to images.
*/
maxPage?: number;
scaleFactor?: number;
}
export interface PdfInfo {
pageCount: number;
}
export interface Implementation {
nativeImage: {
createFromPath: (path: string) => Promise<any>;
createFromPdf: (path: string, options: CreateFromPdfOptions) => Promise<any[]>;
};
getPdfInfo: (path: string) => Promise<PdfInfo>;
}
export interface ResizeOptions {
width?: number;
height?: number;
Expand Down Expand Up @@ -34,9 +57,13 @@ export default class JoplinImaging {
private cacheImage;
createFromPath(filePath: string): Promise<Handle>;
createFromResource(resourceId: string): Promise<Handle>;
createFromPdfPath(path: string, options?: CreateFromPdfOptions): Promise<Handle[]>;
createFromPdfResource(resourceId: string, options?: CreateFromPdfOptions): Promise<Handle[]>;
getPdfInfoFromPath(path: string): Promise<PdfInfo>;
getPdfInfoFromResource(resourceId: string): Promise<PdfInfo>;
getSize(handle: Handle): Promise<any>;
resize(handle: Handle, options?: ResizeOptions): Promise<string>;
crop(handle: Handle, rectange: Rectangle): Promise<string>;
crop(handle: Handle, rectangle: Rectangle): Promise<string>;
toPngFile(handle: Handle, filePath: string): Promise<void>;
/**
* Quality is between 0 and 100
Expand All @@ -57,5 +84,5 @@ export default class JoplinImaging {
* Image data is not automatically deleted by Joplin so make sure you call
* this method on the handle once you are done.
*/
free(handle: Handle): Promise<void>;
free(handles: Handle[] | Handle): Promise<void>;
}
140 changes: 100 additions & 40 deletions packages/app-cli/tests/support/plugins/imaging/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,110 @@
import joplin from 'api';
import { ToolbarButtonLocation } from 'api/types';

joplin.plugins.register({
onStart: async function() {
await joplin.commands.register({
name: 'makeThumbnail',
execute: async () => {
// ---------------------------------------------------------------
// Get the current note
// ---------------------------------------------------------------

const noteIds = await joplin.workspace.selectedNoteIds();
if (noteIds.length !== 1) return;
const noteId = noteIds[0];

// ---------------------------------------------------------------
// Get the top resource in that note (if any)
// ---------------------------------------------------------------

const result = await joplin.data.get(['notes', noteId, 'resources']);
if (result.items.length <= 0) return;
const resource = result.items[0];

// ---------------------------------------------------------------
// Create an image object and resize it
// ---------------------------------------------------------------

const imageHandle = await joplin.imaging.createFromResource(resource.id);
const resizedImageHandle = await joplin.imaging.resize(imageHandle, { width: 100 });
const registerMakeThumbnailCommand = async () => {
await joplin.commands.register({
name: 'makeThumbnail',
execute: async () => {
// ---------------------------------------------------------------
// Get the current note
// ---------------------------------------------------------------

const noteIds = await joplin.workspace.selectedNoteIds();
if (noteIds.length !== 1) return;
const noteId = noteIds[0];

// ---------------------------------------------------------------
// Get the top resource in that note (if any)
// ---------------------------------------------------------------

const result = await joplin.data.get(['notes', noteId, 'resources']);
if (result.items.length <= 0) return;
const resource = result.items[0];

// ---------------------------------------------------------------
// Create an image object and resize it
// ---------------------------------------------------------------

// ---------------------------------------------------------------
// Convert the image to a resource and add it to the note
// ---------------------------------------------------------------
const imageHandle = await joplin.imaging.createFromResource(resource.id);
const resizedImageHandle = await joplin.imaging.resize(imageHandle, { width: 100 });

// ---------------------------------------------------------------
// Convert the image to a resource and add it to the note
// ---------------------------------------------------------------

const newResource = await joplin.imaging.toJpgResource(resizedImageHandle, { title: "Thumbnail" });
await joplin.commands.execute('insertText', '\n![](:/' + newResource.id + ')');

// ---------------------------------------------------------------
// Free up the image objects at the end
// ---------------------------------------------------------------

await joplin.imaging.free(imageHandle);
await joplin.imaging.free(resizedImageHandle);
},
});

await joplin.views.toolbarButtons.create('makeThumbnailButton', 'makeThumbnail', ToolbarButtonLocation.EditorToolbar);
};


const newResource = await joplin.imaging.toJpgResource(resizedImageHandle, { title: "Thumbnail" });
await joplin.commands.execute('insertText', '\n![](:/' + newResource.id + ')');
const registerInlinePdfCommand = async () => {
await joplin.commands.register({
name: 'inlinePdfs',
execute: async () => {
// ---------------------------------------------------------------
// Get the current selection & extract a resource link
// ---------------------------------------------------------------

// ---------------------------------------------------------------
// Free up the image objects at the end
// ---------------------------------------------------------------
const selection: string = await joplin.commands.execute('selectedText');

await joplin.imaging.free(imageHandle);
await joplin.imaging.free(resizedImageHandle);
},
});
// Matches content of the form
// [text here](:/32-letter-or-num-characters-here)
// Where ([a-z0-9]{32}) matches the resource ID.
const resourceLinkRegex = /\[.*\]\(:\/([a-z0-9]{32})\)/;

await joplin.views.toolbarButtons.create('makeThumbnailButton', 'makeThumbnail', ToolbarButtonLocation.EditorToolbar);
const resourceLinkMatch = selection.match(resourceLinkRegex);
if (!resourceLinkMatch) return;
const resourceId = resourceLinkMatch[1]; // The text of the region matching ([a-z0-9]{32})

const resource = await joplin.data.get(['resources', resourceId], { fields: ['mime'] });
const isPdf = resource.mime === 'application/pdf';
if (!isPdf) return;

// Clear the selection
await joplin.commands.execute('replaceSelection', '');
await joplin.commands.execute('insertText', selection);

// ---------------------------------------------------------------
// Convert the PDF to images
// ---------------------------------------------------------------

const pdfInfo = await joplin.imaging.getPdfInfoFromResource(resourceId);
const images = await joplin.imaging.createFromPdfResource(
resourceId,
// Convert at most 10 pages
{ minPage: 1, maxPage: 10, scaleFactor: 0.5 },
);

let pageNumber = 0;
for (const image of images) {
pageNumber++;
const pageResource = await joplin.imaging.toJpgResource(
image, { title: `Page ${pageNumber} of ${pdfInfo.pageCount}` }
);
await joplin.commands.execute('insertText', `\n- ![${pageResource.title}](:/${pageResource.id})`);
}

await joplin.imaging.free(images);
},
});

await joplin.views.toolbarButtons.create('inlineSelectedPdfsButton', 'inlinePdfs', ToolbarButtonLocation.EditorToolbar);
};

joplin.plugins.register({
onStart: async function() {
await registerMakeThumbnailCommand();
await registerInlinePdfCommand();
},
});
36 changes: 34 additions & 2 deletions packages/app-desktop/services/plugins/PlatformImplementation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import { VersionInfo } from '@joplin/lib/services/plugins/api/types';
import Setting from '@joplin/lib/models/Setting';
import { reg } from '@joplin/lib/registry';
import BasePlatformImplementation, { Joplin } from '@joplin/lib/services/plugins/BasePlatformImplementation';
import { Implementation as ImagingImplementation } from '@joplin/lib/services/plugins/api/JoplinImaging';
import { CreateFromPdfOptions, Implementation as ImagingImplementation } from '@joplin/lib/services/plugins/api/JoplinImaging';
import shim from '@joplin/lib/shim';
import { join } from 'path';
import uuid from '@joplin/lib/uuid';
const { clipboard, nativeImage } = require('electron');
const packageInfo = require('../../packageInfo');

Expand Down Expand Up @@ -82,8 +85,37 @@ export default class PlatformImplementation extends BasePlatformImplementation {
}

public get imaging(): ImagingImplementation {
const createFromPdf = async (path: string, options: CreateFromPdfOptions) => {
const tempDir = join(Setting.value('tempDir'), uuid.createNano());
await shim.fsDriver().mkdir(tempDir);
try {
const paths = await shim.pdfToImages(path, tempDir, options);
return paths.map(path => nativeImage.createFromPath(path));
} finally {
await shim.fsDriver().remove(tempDir);
}
};
return {
nativeImage: nativeImage,
nativeImage: {
async createFromPath(path: string) {
if (path.toLowerCase().endsWith('.pdf')) {
const images = await createFromPdf(path, { minPage: 1, maxPage: 1 });

if (images.length === 0) {
// Match the behavior or Electron's nativeImage when reading an invalid image.
return nativeImage.createEmpty();
}

return images[0];
} else {
return nativeImage.createFromPath(path);
}
},
createFromPdf,
},
getPdfInfo(path: string) {
return shim.pdfInfo(path);
},
};
}

Expand Down
3 changes: 3 additions & 0 deletions packages/app-mobile/plugins/PlatformImplementation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ export default class PlatformImplementation extends BasePlatformImplementation {
public get imaging(): ImagingImplementation {
return {
nativeImage: null,
getPdfInfo: async () => {
throw new Error('Not implemented: getPdfInfo');
},
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,35 @@
import { Rectangle } from './types';
export interface Implementation {
nativeImage: any;
}
export interface CreateFromBufferOptions {
width?: number;
height?: number;
scaleFactor?: number;
}
export interface CreateFromPdfOptions {
/**
* The first page to export. Defaults to `1`, the first page in
* the document.
*/
minPage?: number;
/**
* The number of the last page to convert. Defaults to the last page
* if not given.
*
* If `maxPage` is greater than the number of pages in the PDF, all pages
* in the PDF will be converted to images.
*/
maxPage?: number;
scaleFactor?: number;
}
export interface PdfInfo {
pageCount: number;
}
export interface Implementation {
nativeImage: {
createFromPath: (path: string) => Promise<any>;
createFromPdf: (path: string, options: CreateFromPdfOptions) => Promise<any[]>;
};
getPdfInfo: (path: string) => Promise<PdfInfo>;
}
export interface ResizeOptions {
width?: number;
height?: number;
Expand Down Expand Up @@ -34,6 +57,10 @@ export default class JoplinImaging {
private cacheImage;
createFromPath(filePath: string): Promise<Handle>;
createFromResource(resourceId: string): Promise<Handle>;
createFromPdfPath(path: string, options?: CreateFromPdfOptions): Promise<Handle[]>;
createFromPdfResource(resourceId: string, options?: CreateFromPdfOptions): Promise<Handle[]>;
getPdfInfoFromPath(path: string): Promise<PdfInfo>;
getPdfInfoFromResource(resourceId: string): Promise<PdfInfo>;
getSize(handle: Handle): Promise<any>;
resize(handle: Handle, options?: ResizeOptions): Promise<string>;
crop(handle: Handle, rectangle: Rectangle): Promise<string>;
Expand All @@ -57,5 +84,5 @@ export default class JoplinImaging {
* Image data is not automatically deleted by Joplin so make sure you call
* this method on the handle once you are done.
*/
free(handle: Handle): Promise<void>;
free(handles: Handle[] | Handle): Promise<void>;
}
Loading

0 comments on commit 06aa640

Please sign in to comment.