@hidemikimura/receipt-html-to-pdf 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,52 @@
1
+ // Lit(Web Components)サンプル。
2
+ // 注意: Shadow DOM 内の要素を渡すと、親文書のスタイルシートは継承されない。
3
+ // 変換用の HTML は light DOM に置く、または `stylesheets` オプションで CSS を明示的に渡す。
4
+ import { LitElement, html, css, unsafeCSS } from 'lit';
5
+ import { registerFont, htmlToPdf, downloadPdf } from '@hidemikimura/receipt-html-to-pdf';
6
+
7
+ const fontsReady = Promise.all([
8
+ registerFont({ family: 'BIZ UDPGothic', weight: 400, src: '/fonts/BIZUDPGothic-Regular.ttf' }),
9
+ registerFont({ family: 'BIZ UDPGothic', weight: 700, src: '/fonts/BIZUDPGothic-Bold.ttf' }),
10
+ ]);
11
+
12
+ // PDF 側にも同じスタイルを渡す(Shadow DOM の CSS は inherit で拾えないため)
13
+ const RECEIPT_CSS = `
14
+ @font-face { font-family: "BIZ UDPGothic"; font-weight: 400; src: url("/fonts/BIZUDPGothic-Regular.ttf"); }
15
+ @font-face { font-family: "BIZ UDPGothic"; font-weight: 700; src: url("/fonts/BIZUDPGothic-Bold.ttf"); }
16
+ .receipt { font-family: "BIZ UDPGothic", sans-serif; width: 180mm; }
17
+ .receipt h1 { text-align: center; letter-spacing: .5em; }
18
+ .amount { font-size: 24pt; font-weight: 700; text-align: center; }
19
+ `;
20
+
21
+ export class ReceiptPdf extends LitElement {
22
+ static properties = { customer: {}, total: { type: Number }, busy: { state: true } };
23
+ static styles = [css`:host { display: block; }`, unsafeCSS(RECEIPT_CSS)];
24
+
25
+ async download() {
26
+ this.busy = true;
27
+ try {
28
+ await fontsReady;
29
+ const el = this.renderRoot.querySelector('.receipt');
30
+ const pdf = await htmlToPdf(el, {
31
+ stylesheets: [RECEIPT_CSS],
32
+ page: { size: 'A4', margin: '15mm' },
33
+ metadata: { title: '領収証' },
34
+ });
35
+ downloadPdf(pdf, 'receipt.pdf');
36
+ } finally {
37
+ this.busy = false;
38
+ }
39
+ }
40
+
41
+ render() {
42
+ return html`
43
+ <button @click=${this.download} ?disabled=${this.busy}>${this.busy ? '変換中…' : 'PDF をダウンロード'}</button>
44
+ <article class="receipt">
45
+ <h1>領収証</h1>
46
+ <p>${this.customer} 御中</p>
47
+ <p class="amount">¥${this.total?.toLocaleString('ja-JP')}-</p>
48
+ </article>
49
+ `;
50
+ }
51
+ }
52
+ customElements.define('receipt-pdf', ReceiptPdf);
@@ -0,0 +1,56 @@
1
+ // React サンプル(JSX)。バンドラー(Vite など)で使う想定。
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { registerFont, htmlToPdf, downloadPdf } from '@hidemikimura/receipt-html-to-pdf';
4
+
5
+ // モジュール読み込み時に 1 回だけフォントを登録する
6
+ const fontsReady = Promise.all([
7
+ registerFont({ family: 'BIZ UDPGothic', weight: 400, src: '/fonts/BIZUDPGothic-Regular.ttf' }),
8
+ registerFont({ family: 'BIZ UDPGothic', weight: 700, src: '/fonts/BIZUDPGothic-Bold.ttf' }),
9
+ ]);
10
+
11
+ export function ReceiptPdfButton({ receiptRef, filename = 'receipt.pdf' }) {
12
+ const [busy, setBusy] = useState(false);
13
+ const [error, setError] = useState(null);
14
+
15
+ async function handleClick() {
16
+ setBusy(true);
17
+ setError(null);
18
+ try {
19
+ await fontsReady;
20
+ const pdf = await htmlToPdf(receiptRef.current, {
21
+ page: { size: 'A4', margin: '15mm' },
22
+ footer: '<div style="text-align:center;font-size:8pt">{{pageNumber}} / {{totalPages}}</div>',
23
+ onWarning: (w) => console.warn(w.code, w.message),
24
+ });
25
+ downloadPdf(pdf, filename);
26
+ } catch (e) {
27
+ setError(e instanceof Error ? e.message : String(e));
28
+ } finally {
29
+ setBusy(false);
30
+ }
31
+ }
32
+
33
+ return (
34
+ <>
35
+ <button onClick={handleClick} disabled={busy}>{busy ? '変換中…' : 'PDF をダウンロード'}</button>
36
+ {error && <span role="alert">{error}</span>}
37
+ </>
38
+ );
39
+ }
40
+
41
+ export function ReceiptPage({ receipt }) {
42
+ const ref = useRef(null);
43
+ useEffect(() => {
44
+ // 変換対象の要素はスタイルシート(@font-face 含む)が適用された状態で DOM 上にあること
45
+ }, []);
46
+ return (
47
+ <div>
48
+ <ReceiptPdfButton receiptRef={ref} filename={`receipt-${receipt.no}.pdf`} />
49
+ <article ref={ref} className="receipt">
50
+ <h1>領収証</h1>
51
+ <p>{receipt.customer} 御中</p>
52
+ <p className="amount">¥{receipt.total.toLocaleString('ja-JP')}-</p>
53
+ </article>
54
+ </div>
55
+ );
56
+ }
@@ -0,0 +1,55 @@
1
+ <!DOCTYPE html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Receipt html to pdf — Vanilla JS サンプル</title>
6
+ <style>
7
+ @font-face { font-family: "BIZ UDPGothic"; font-weight: 400; src: url("../fonts/BIZUDPGothic-Regular.ttf"); }
8
+ @font-face { font-family: "BIZ UDPGothic"; font-weight: 700; src: url("../fonts/BIZUDPGothic-Bold.ttf"); }
9
+ body { font-family: "BIZ UDPGothic", sans-serif; margin: 24px; }
10
+ #receipt { width: 180mm; border: 1px solid #ccc; padding: 10mm; box-sizing: border-box; }
11
+ h1 { text-align: center; letter-spacing: .5em; }
12
+ .amount { font-size: 24pt; font-weight: 700; text-align: center; margin: 8mm 0; }
13
+ </style>
14
+ </head>
15
+ <body>
16
+ <button id="download">PDF をダウンロード</button>
17
+ <span id="status"></span>
18
+
19
+ <article id="receipt">
20
+ <h1>領収証</h1>
21
+ <p>株式会社テスト商事 御中</p>
22
+ <p class="amount">¥41,936-</p>
23
+ <p>但し、商品代として上記金額を正に領収いたしました。</p>
24
+ </article>
25
+
26
+ <script type="module">
27
+ // 同じリポジトリ内で試す場合。npm でインストールした場合は
28
+ // import { registerFont, htmlToPdf, downloadPdf } from '@hidemikimura/receipt-html-to-pdf';
29
+ import { registerFont, htmlToPdf, downloadPdf } from '../src/index.js';
30
+
31
+ // フォントは 1 回だけ登録する(@font-face と同じファイルを渡す)
32
+ const fontsReady = Promise.all([
33
+ registerFont({ family: 'BIZ UDPGothic', weight: 400, src: '../fonts/BIZUDPGothic-Regular.ttf' }),
34
+ registerFont({ family: 'BIZ UDPGothic', weight: 700, src: '../fonts/BIZUDPGothic-Bold.ttf' }),
35
+ ]);
36
+
37
+ document.getElementById('download').addEventListener('click', async () => {
38
+ const status = document.getElementById('status');
39
+ status.textContent = '変換中…';
40
+ try {
41
+ await fontsReady;
42
+ const pdf = await htmlToPdf(document.getElementById('receipt'), {
43
+ page: { size: 'A4', margin: '15mm' },
44
+ metadata: { title: '領収証', author: '株式会社サンプル商店' },
45
+ onWarning: (w) => console.warn('[receipt-html-to-pdf]', w.code, w.message),
46
+ });
47
+ downloadPdf(pdf, 'receipt.pdf');
48
+ status.textContent = `完了(${Math.round(pdf.size / 1024)} KB)`;
49
+ } catch (e) {
50
+ status.textContent = `失敗: ${e.message}`;
51
+ }
52
+ });
53
+ </script>
54
+ </body>
55
+ </html>
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "@hidemikimura/receipt-html-to-pdf",
3
+ "version": "0.1.0",
4
+ "description": "Receipt html to pdf — ブラウザ内で HTML/CSS をテキスト選択可能なベクター PDF に変換するライブラリ(日本の領収証・適格請求書向け)",
5
+ "license": "MIT",
6
+ "author": "Hidemi Kimura",
7
+ "homepage": "https://github.com/hidemikimura/receipt-html-to-pdf#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/hidemikimura/receipt-html-to-pdf.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/hidemikimura/receipt-html-to-pdf/issues"
14
+ },
15
+ "type": "module",
16
+ "main": "./src/index.js",
17
+ "module": "./src/index.js",
18
+ "types": "./types/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./types/index.d.ts",
22
+ "import": "./src/index.js"
23
+ },
24
+ "./min": {
25
+ "types": "./types/index.d.ts",
26
+ "import": "./dist/receipt-html-to-pdf.min.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "src",
31
+ "dist/receipt-html-to-pdf.min.js",
32
+ "dist/receipt-html-to-pdf.min.js.map",
33
+ "types",
34
+ "examples",
35
+ "README.md",
36
+ "LICENSE",
37
+ "CHANGELOG.md"
38
+ ],
39
+ "sideEffects": false,
40
+ "engines": {
41
+ "node": ">=20"
42
+ },
43
+ "scripts": {
44
+ "typecheck": "tsc -p jsconfig.json",
45
+ "types": "tsc -p jsconfig.json --declaration --emitDeclarationOnly --noEmit false --outDir types",
46
+ "build": "node scripts/build.mjs",
47
+ "test": "vitest run",
48
+ "test:watch": "vitest",
49
+ "test:browser": "playwright test",
50
+ "e2e": "node scripts/e2e.mjs && node scripts/verify.mjs && node scripts/e2e.mjs receipt-invoice reissue && node scripts/verify.mjs receipt-invoice reissue && node scripts/e2e.mjs receipt-invoice-long && node scripts/verify.mjs receipt-invoice-long",
51
+ "fixtures": "node scripts/gen-fixture-long.mjs",
52
+ "fonts": "sh scripts/fetch-fonts.sh",
53
+ "dev": "npx --yes serve -l 5173 .",
54
+ "prepublishOnly": "npm run typecheck && npm test && npm run types && npm run build -- --check",
55
+ "pack:check": "npm pack --dry-run"
56
+ },
57
+ "devDependencies": {
58
+ "@playwright/test": "^1.50.0",
59
+ "esbuild": "^0.28.2",
60
+ "pdfjs-dist": "^4.10.0",
61
+ "pixelmatch": "^7.2.0",
62
+ "pngjs": "^7.0.0",
63
+ "typescript": "^5.7.0",
64
+ "vitest": "^3.0.0"
65
+ },
66
+ "keywords": [
67
+ "pdf",
68
+ "html-to-pdf",
69
+ "receipt",
70
+ "invoice",
71
+ "browser",
72
+ "vector",
73
+ "japanese",
74
+ "invoice-jp",
75
+ "receipt-jp",
76
+ "vector-pdf",
77
+ "truetype-subset"
78
+ ],
79
+ "publishConfig": {
80
+ "access": "public"
81
+ }
82
+ }
@@ -0,0 +1,187 @@
1
+ // @ts-check
2
+ /**
3
+ * サブセット化した TrueType を PDF の Type0 / CIDFontType2 として埋め込む。
4
+ * CID = サブセット後の GID(CIDToGIDMap /Identity)、エンコーディングは Identity-H。
5
+ * ToUnicode CMap を必ず付けてテキスト選択・検索・コピーを保証する。
6
+ */
7
+ import { Raw } from '../pdf/writer.js';
8
+ import { subsetFont } from './subset.js';
9
+
10
+ /**
11
+ * PDF に埋め込む 1 フォント分の使用状況。
12
+ * 走査中に addGlyph でグリフを集め、最後に embed で PDF オブジェクトを生成する。
13
+ */
14
+ export class EmbeddedFont {
15
+ /**
16
+ * @param {import('./parse.js').ParsedFont} font
17
+ * @param {string} resourceName ページリソース名(F1 など)
18
+ */
19
+ constructor(font, resourceName) {
20
+ this.font = font;
21
+ this.resourceName = resourceName;
22
+ /** @type {Map<number, number>} 旧 GID → 代表コードポイント(ToUnicode 用) */
23
+ this.usedGids = new Map();
24
+ /** @type {import('./subset.js').SubsetResult|null} */
25
+ this.subset = null;
26
+ }
27
+
28
+ /**
29
+ * @param {number} gid 旧 GID
30
+ * @param {number} codePoint
31
+ */
32
+ addGlyph(gid, codePoint) {
33
+ if (!this.usedGids.has(gid)) this.usedGids.set(gid, codePoint);
34
+ }
35
+
36
+ /**
37
+ * 旧 GID を PDF 上の CID(サブセット後 GID)へ変換する。embed 後にのみ有効。
38
+ * @param {number} gid
39
+ * @returns {number}
40
+ */
41
+ cid(gid) {
42
+ if (!this.subset) throw new Error('EmbeddedFont: embed() must be called before cid()');
43
+ return this.subset.gidMap.get(gid) ?? 0;
44
+ }
45
+
46
+ /**
47
+ * サブセット化を確定させる。以後 cid() が使える。
48
+ */
49
+ finalize() {
50
+ if (!this.subset) this.subset = subsetFont(this.font, this.usedGids.keys());
51
+ return this.subset;
52
+ }
53
+
54
+ /**
55
+ * PDF オブジェクト群を書き込み、Type0 フォント辞書への参照を返す。
56
+ * @param {import('../pdf/writer.js').PdfWriter} writer
57
+ * @returns {Promise<import('../pdf/writer.js').Ref>}
58
+ */
59
+ async embed(writer) {
60
+ const subset = this.finalize();
61
+ const f = this.font;
62
+ const scale = 1000 / f.unitsPerEm;
63
+ const tag = subsetTag(this.usedGids);
64
+ const baseFont = `${tag}+${f.postScriptName}`;
65
+
66
+ const fontFile = await writer.addStream({ Length1: subset.data.length }, subset.data);
67
+
68
+ // フラグ: bit 3 (Symbolic) を立てる。Nonsymbolic と排他。Italic は bit 7、ForceBold は bit 19。
69
+ let flags = 4;
70
+ if (f.italic) flags |= 1 << 6;
71
+ if (f.bold) flags |= 1 << 18;
72
+
73
+ const descriptor = writer.add({
74
+ Type: 'FontDescriptor',
75
+ FontName: baseFont,
76
+ Flags: flags,
77
+ FontBBox: f.bbox.map((v) => Math.round(v * scale)),
78
+ ItalicAngle: f.italicAngle,
79
+ Ascent: Math.round(f.ascender * scale),
80
+ Descent: Math.round(f.descender * scale),
81
+ CapHeight: Math.round(f.capHeight * scale),
82
+ StemV: f.bold ? 120 : 80,
83
+ FontFile2: fontFile,
84
+ });
85
+
86
+ // W 配列: 連続 CID をまとめる c [w1 w2 ...] 形式
87
+ /** @type {import('../pdf/writer.js').PdfValue[]} */
88
+ const W = [];
89
+ const widths = subset.oldGids.map((g) => Math.round((f.advances[g] ?? 0) * scale));
90
+ let i = 0;
91
+ while (i < widths.length) {
92
+ let j = i;
93
+ while (j + 1 < widths.length && j - i < 100) j++;
94
+ W.push(i, widths.slice(i, j + 1));
95
+ i = j + 1;
96
+ }
97
+
98
+ const cidFont = writer.add({
99
+ Type: 'Font',
100
+ Subtype: 'CIDFontType2',
101
+ BaseFont: baseFont,
102
+ CIDSystemInfo: { Registry: new Raw('(Adobe)'), Ordering: new Raw('(Identity)'), Supplement: 0 },
103
+ FontDescriptor: descriptor,
104
+ DW: 1000,
105
+ W,
106
+ CIDToGIDMap: 'Identity',
107
+ });
108
+
109
+ const toUnicode = await writer.addStream({}, buildToUnicode(subset, this.usedGids));
110
+
111
+ return writer.add({
112
+ Type: 'Font',
113
+ Subtype: 'Type0',
114
+ BaseFont: baseFont,
115
+ Encoding: 'Identity-H',
116
+ DescendantFonts: [cidFont],
117
+ ToUnicode: toUnicode,
118
+ });
119
+ }
120
+ }
121
+
122
+ /**
123
+ * サブセットフォント名の接頭辞(6 文字の大文字)。使用グリフ集合から決定的に生成する。
124
+ * @param {Map<number, number>} usedGids
125
+ * @returns {string}
126
+ */
127
+ function subsetTag(usedGids) {
128
+ let h = 2166136261;
129
+ for (const g of usedGids.keys()) {
130
+ h ^= g;
131
+ h = Math.imul(h, 16777619) >>> 0;
132
+ }
133
+ let tag = '';
134
+ for (let i = 0; i < 6; i++) {
135
+ tag += String.fromCharCode(65 + (h % 26));
136
+ h = Math.floor(h / 26);
137
+ }
138
+ return tag;
139
+ }
140
+
141
+ /**
142
+ * ToUnicode CMap(bfchar 形式)を生成する。
143
+ * @param {import('./subset.js').SubsetResult} subset
144
+ * @param {Map<number, number>} usedGids 旧 GID → コードポイント
145
+ * @returns {Uint8Array}
146
+ */
147
+ function buildToUnicode(subset, usedGids) {
148
+ /** @type {string[]} */
149
+ const entries = [];
150
+ subset.oldGids.forEach((oldGid, cid) => {
151
+ const cp = usedGids.get(oldGid);
152
+ if (cp === undefined) return;
153
+ entries.push(`<${hex4(cid)}> <${utf16Hex(cp)}>`);
154
+ });
155
+
156
+ let body = '';
157
+ for (let i = 0; i < entries.length; i += 100) {
158
+ const chunk = entries.slice(i, i + 100);
159
+ body += `${chunk.length} beginbfchar\n${chunk.join('\n')}\nendbfchar\n`;
160
+ }
161
+
162
+ const cmap =
163
+ `/CIDInit /ProcSet findresource begin\n` +
164
+ `12 dict begin\nbegincmap\n` +
165
+ `/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n` +
166
+ `/CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n` +
167
+ `1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n` +
168
+ body +
169
+ `endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n`;
170
+ return new TextEncoder().encode(cmap);
171
+ }
172
+
173
+ /** @param {number} n */
174
+ export function hex4(n) {
175
+ return n.toString(16).toUpperCase().padStart(4, '0');
176
+ }
177
+
178
+ /**
179
+ * コードポイントを UTF-16BE の 16 進表記にする(サロゲートペア対応)。
180
+ * @param {number} cp
181
+ */
182
+ function utf16Hex(cp) {
183
+ if (cp <= 0xffff) return hex4(cp);
184
+ const v = cp - 0x10000;
185
+ return hex4(0xd800 + (v >> 10)) + hex4(0xdc00 + (v & 0x3ff));
186
+ }
187
+