Skip to content

Commit 7451118

Browse files
ECgearclaude
andcommitted
fix: include src/build sources excluded by gitignore
The template .gitignore ignored 'build/' (a build-output convention), which also matched the source directory src/build/ — so images-to-pdf.ts and page-sizes.ts were never committed and CI's clean checkout failed to build. Anchor the ignore to /build/ (repo root only) and add the two source files. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent afaa1e7 commit 7451118

3 files changed

Lines changed: 165 additions & 3 deletions

File tree

.gitignore

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
# Rename to .gitignore
22

3-
# Dependencies / build
3+
# Dependencies / build (anchored to repo root so source dirs like src/build/ are kept)
44
node_modules/
5-
dist/
6-
build/
5+
/dist/
6+
/build/
77
coverage/
88
*.tsbuildinfo
99

src/build/images-to-pdf.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/** Combine multiple images into one PDF (pdf-lib). JPEG/PNG embed natively; other
2+
* formats (WebP/GIF/BMP/AVIF) are decoded on a canvas and re-encoded to JPEG. */
3+
import { PDFDocument, type PDFImage } from 'pdf-lib';
4+
import { fitRect, resolvePageSize } from './page-sizes';
5+
import { bytesToImageData } from '../codecs/canvas';
6+
import { encodeJpeg } from '../codecs/jpeg';
7+
import { throwIfAborted, type AbortLike } from '../util/errors';
8+
import type { FitMode, ImagesToPdfResult, OnProgress, Orientation, PageSize } from '../types';
9+
10+
export interface ImagesToPdfParams {
11+
pageSize: PageSize;
12+
fit: FitMode;
13+
orientation: Orientation;
14+
margin: number;
15+
jpegWasmUrl: string;
16+
}
17+
18+
export interface NamedImage {
19+
name: string;
20+
bytes: Uint8Array;
21+
}
22+
23+
function isJpeg(b: Uint8Array): boolean {
24+
return b.length > 2 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff;
25+
}
26+
function isPng(b: Uint8Array): boolean {
27+
return b.length > 7 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47;
28+
}
29+
30+
export async function buildPdfFromImages(
31+
items: NamedImage[],
32+
params: ImagesToPdfParams,
33+
onProgress?: OnProgress,
34+
signal?: AbortLike,
35+
): Promise<ImagesToPdfResult> {
36+
const out = await PDFDocument.create();
37+
const skipped: string[] = [];
38+
const total = items.length;
39+
const margin = Math.max(0, params.margin || 0);
40+
41+
for (let i = 0; i < total; i++) {
42+
throwIfAborted(signal);
43+
const { name, bytes } = items[i];
44+
try {
45+
let image: PDFImage;
46+
if (isJpeg(bytes)) {
47+
image = await out.embedJpg(bytes);
48+
} else if (isPng(bytes)) {
49+
image = await out.embedPng(bytes);
50+
} else {
51+
const imageData = await bytesToImageData(bytes, { fillWhite: true });
52+
const jpeg = await encodeJpeg(imageData, 90, params.jpegWasmUrl);
53+
image = await out.embedJpg(jpeg);
54+
}
55+
const imgW = image.width;
56+
const imgH = image.height;
57+
const { width: pw, height: ph } = resolvePageSize(params.pageSize, params.orientation, imgW, imgH);
58+
const page = out.addPage([pw, ph]);
59+
const boxW = Math.max(1, pw - margin * 2);
60+
const boxH = Math.max(1, ph - margin * 2);
61+
const rect =
62+
params.pageSize === 'fit'
63+
? { x: 0, y: 0, width: boxW, height: boxH }
64+
: fitRect(imgW, imgH, boxW, boxH, params.fit);
65+
page.drawImage(image, {
66+
x: margin + rect.x,
67+
y: margin + rect.y,
68+
width: rect.width,
69+
height: rect.height,
70+
});
71+
} catch {
72+
skipped.push(name);
73+
}
74+
onProgress?.({ phase: 'assemble', done: i + 1, total, label: `画像を追加中… ${i + 1} / ${total}` });
75+
}
76+
77+
const bytes = await out.save();
78+
return { bytes, pages: out.getPageCount(), skipped };
79+
}

src/build/page-sizes.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import type { FitMode, Orientation, PageSize } from '../types';
2+
3+
export const PT_PER_INCH = 72;
4+
5+
/** Portrait dimensions in PDF points (1pt = 1/72 inch). */
6+
export const PAGE_DIMENSIONS_PT: Record<'a4' | 'letter', readonly [number, number]> = {
7+
a4: [595.28, 841.89],
8+
letter: [612, 792],
9+
};
10+
11+
export interface Rect {
12+
x: number;
13+
y: number;
14+
width: number;
15+
height: number;
16+
}
17+
18+
function applyOrientation(w: number, h: number, orientation: Orientation, imgW: number, imgH: number): [number, number] {
19+
let landscape: boolean;
20+
if (orientation === 'portrait') landscape = false;
21+
else if (orientation === 'landscape') landscape = true;
22+
else landscape = imgW > imgH; // auto: follow the image
23+
const long = Math.max(w, h);
24+
const short = Math.min(w, h);
25+
return landscape ? [long, short] : [short, long];
26+
}
27+
28+
/** Resolve the page box (in points) for one image. */
29+
export function resolvePageSize(
30+
pageSize: PageSize,
31+
orientation: Orientation,
32+
imgWpx: number,
33+
imgHpx: number,
34+
): { width: number; height: number } {
35+
if (pageSize === 'fit') {
36+
// page matches the image, mapping pixels 1:1 to points (96dpi-ish baseline)
37+
const landscape =
38+
orientation === 'landscape' || (orientation === 'auto' && imgWpx > imgHpx);
39+
const w = imgWpx;
40+
const h = imgHpx;
41+
if (orientation === 'portrait' && w > h) return { width: h, height: w };
42+
if (landscape && w < h) return { width: h, height: w };
43+
return { width: w, height: h };
44+
}
45+
const [pw, ph] = PAGE_DIMENSIONS_PT[pageSize];
46+
const [width, height] = applyOrientation(pw, ph, orientation, imgWpx, imgHpx);
47+
return { width, height };
48+
}
49+
50+
/**
51+
* Place an image of (imgW x imgH) inside a content box of (boxW x boxH) given a
52+
* fit mode. Returns the draw rect (origin at bottom-left, PDF convention).
53+
*/
54+
export function fitRect(
55+
imgW: number,
56+
imgH: number,
57+
boxW: number,
58+
boxH: number,
59+
fit: FitMode,
60+
): Rect {
61+
if (imgW <= 0 || imgH <= 0 || boxW <= 0 || boxH <= 0) {
62+
return { x: 0, y: 0, width: Math.max(0, boxW), height: Math.max(0, boxH) };
63+
}
64+
let drawW: number;
65+
let drawH: number;
66+
if (fit === 'actual') {
67+
drawW = imgW;
68+
drawH = imgH;
69+
} else {
70+
const scale =
71+
fit === 'cover'
72+
? Math.max(boxW / imgW, boxH / imgH)
73+
: Math.min(boxW / imgW, boxH / imgH); // 'contain'
74+
drawW = imgW * scale;
75+
drawH = imgH * scale;
76+
}
77+
return {
78+
x: (boxW - drawW) / 2,
79+
y: (boxH - drawH) / 2,
80+
width: drawW,
81+
height: drawH,
82+
};
83+
}

0 commit comments

Comments
 (0)