Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Desktop: Resolves #9794: Plugin API: Add support for loading PDFs with the imaging API #10177

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
@@ -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;
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a follow up pull request, is there any additional info we can retrieve using pdf.js? I see that there's a doc.getMetadata() which maybe we could use.

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
@@ -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
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
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
@@ -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[]>;
personalizedrefrigerator marked this conversation as resolved.
Show resolved Hide resolved
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>;
}