Skip to content

feat(tex): support page parameter for includegraphics with multi-page pdf #1922

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

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/spicy-dingos-talk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"myst-cli": patch
"tex-to-myst": patch
---

Support page parameter for includegraphics with multi-page pdf
In LaTeX, when including a multi-page PDF as a graphic, it's possible to specify a page number:
```
\includegraphics[width=1.0\textwidth,page=3]{figures/my_pic.pdf}
```
ImageMagick now extracts the correct page when converting from LaTeX.
13 changes: 10 additions & 3 deletions packages/myst-cli/src/transforms/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ type ConversionOpts = {
imagemagickAvailable: boolean;
dwebpAvailable: boolean;
ffmpegAvailable: boolean;
page?: number;
};

type ConversionFn = (
Expand All @@ -270,7 +271,8 @@ function imagemagickConvert(
return async (session: ISession, source: string, writeFolder: string, opts: ConversionOpts) => {
const { imagemagickAvailable } = opts;
if (imagemagickAvailable) {
return imagemagick.convert(from, to, session, source, writeFolder, options);
const optsWithPage = opts.page !== undefined ? { ...options, page: opts.page } : options;
return imagemagick.convert(from, to, session, source, writeFolder, optsWithPage);
}
return null;
};
Expand Down Expand Up @@ -479,13 +481,18 @@ export async function transformImageFormats(
let outputFile: string | null = null;
for (const conversionFn of conversionFns) {
if (!outputFile) {
outputFile = await conversionFn(session, inputFile, writeFolder, {
const conversionOpts: ConversionOpts = {
file,
inkscapeAvailable,
imagemagickAvailable,
dwebpAvailable,
ffmpegAvailable,
});
};
if (image.page !== undefined) {
conversionOpts.page = image.page;
}

outputFile = await conversionFn(session, inputFile, writeFolder, conversionOpts);
}
}
if (outputFile) {
Expand Down
7 changes: 4 additions & 3 deletions packages/myst-cli/src/utils/imagemagick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,22 +138,23 @@ export async function convert(
session: ISession,
input: string,
writeFolder: string,
options?: { trim?: boolean },
options?: { trim?: boolean; page?: number },
) {
if (!fs.existsSync(input)) return null;
const { name, ext } = path.parse(input);
if (ext !== inputExtension) return null;
const filename = `${name}${outputExtension}`;
const filename = `${name}${options?.page ? '-' + options.page : ''}${outputExtension}`;
const output = path.join(writeFolder, filename);
const inputFormatUpper = inputExtension.slice(1).toUpperCase();
const outputFormatUpper = outputExtension.slice(1).toUpperCase();
if (fs.existsSync(output)) {
session.log.debug(`Cached file found for converted ${inputFormatUpper}: ${input}`);
return filename;
} else {
const executable = `${imageMagickCommand()} -density 600 -colorspace RGB ${input}${
const executable = `${imageMagickCommand()} -density 600 -colorspace RGB ${input}${options?.page ? '[' + options.page + ']' : ''}${
options?.trim ? ' -trim' : ''
} ${output}`;

session.log.debug(`Executing: ${executable}`);
const exec = makeExecutable(executable, createImagemagikLogger(session));
try {
Expand Down
2 changes: 2 additions & 0 deletions packages/myst-spec-ext/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ export type Image = SpecImage & {
urlOptimized?: string;
height?: string;
placeholder?: boolean;
/** Optional page number for PDF images, this ensure the correct page is extracted when converting to web and translated to LaTeX */
page?: boolean;
};

export type Iframe = Target & {
Expand Down
37 changes: 26 additions & 11 deletions packages/tex-to-myst/src/figures.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { GenericNode } from 'myst-common';
import { u } from 'unist-builder';
import type { Handler, ITexParser } from './types.js';
import { getArguments, texToText } from './utils.js';
import { getArguments, extractParams, texToText } from './utils.js';

function renderCaption(node: GenericNode, state: ITexParser) {
state.closeParagraph();
Expand Down Expand Up @@ -45,18 +45,33 @@ const FIGURE_HANDLERS: Record<string, Handler> = {
state.closeParagraph();
const url = texToText(getArguments(node, 'group'));
const args = getArguments(node, 'argument')?.[0]?.content ?? [];
const params = extractParams(args);

// Only support width and page for now
for (const key in params) {
if (key !== 'width' && key !== 'page') {
delete params[key];
}
}

// TODO: better width, placement, etc.
if (
args.length === 4 &&
args[0].content === 'width' &&
args[1].content === '=' &&
Number.isFinite(Number.parseFloat(args[2].content))
) {
const width = `${Math.round(Number.parseFloat(args[2].content) * 100)}%`;
state.pushNode(u('image', { url, width }));
} else {
state.pushNode(u('image', { url }));

// Convert width to percentage if present
if (params.width) {
if (typeof params.width === 'number' && Number.isFinite(params.width)) {
params.width = `${Math.round(params.width * 100)}%`;
} else {
delete params.width; // If width is a string, we don't know what it is, so we ignore it
}
}
if (params.page) {
if (typeof params.page === 'number' && Number.isFinite(params.page)) {
params.page = Math.round(Number(params.page)) - 1; // Convert to 0-based for imagemagick
} else {
delete params.page;
}
}
state.pushNode(u('image', { url: url, ...params }));
},
macro_caption: renderCaption,
macro_captionof: renderCaption,
Expand Down
17 changes: 17 additions & 0 deletions packages/tex-to-myst/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,23 @@ export function getArguments(
);
}

export function extractParams(args: { content: string }[]): Record<string, string | number> {
const params: Record<string, string | number> = {};

for (let i = 0; i < args.length - 2; i++) {
const param = args[i].content;
const equalsSign = args[i + 1].content;
const value = args[i + 2].content;

if (equalsSign === '=' && (Number.isFinite(Number.parseFloat(value)) || value)) {
params[param] = Number.isFinite(Number.parseFloat(value)) ? Number.parseFloat(value) : value;
i += 2; // Skip the processed elements
}
}

return params;
}

export function renderInfoIndex(node: GenericNode, name: string): number {
return node._renderInfo?.namedArguments?.findIndex((a: string) => a === name);
}
Expand Down
34 changes: 34 additions & 0 deletions packages/tex-to-myst/tests/figures.yml
Original file line number Diff line number Diff line change
Expand Up @@ -225,3 +225,37 @@ cases:
value: link to notebook
- type: text
value: '.'
- title: includegraphics specifies the page
tex: |-
\begin{figure}[htbp]
\centering
\includegraphics[width=1.0\textwidth,page=3]{figures/my_pic.pdf}
\caption{ This is the caption, \href{computations.ipynb}{link to notebook}.}
\label{fig:picture}
\end{figure}
tree:
type: root
children:
- type: container
kind: figure
identifier: fig:picture
label: fig:picture
align: center
children:
- type: image
url: figures/my_pic.pdf
width: 100%
page: 2 # start from 0
- type: caption
children:
- type: paragraph
children:
- type: text
value: 'This is the caption, '
- type: link
url: computations.ipynb
children:
- type: text
value: link to notebook
- type: text
value: '.'