@jjlmoya/utils-converters 1.23.0 → 1.24.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.
Files changed (87) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +4 -0
  3. package/src/entries.ts +4 -1
  4. package/src/index.ts +3 -0
  5. package/src/shared/ImageToPdfConverter.astro +80 -0
  6. package/src/shared/image-to-pdf/controller.ts +173 -0
  7. package/src/shared/image-to-pdf/dom-views.ts +55 -0
  8. package/src/shared/image-to-pdf/evaluator.ts +18 -0
  9. package/src/shared/image-to-pdf/logic.test.ts +29 -0
  10. package/src/shared/image-to-pdf/logic.ts +214 -0
  11. package/src/shared/image-to-pdf/storage.ts +36 -0
  12. package/src/shared/image-to-pdf/ui.ts +39 -0
  13. package/src/shared/image-to-pdf.css +394 -0
  14. package/src/tests/i18n_coverage.test.ts +2 -3
  15. package/src/tests/locale_completeness.test.ts +2 -3
  16. package/src/tests/seo_translation_completeness.test.ts +69 -0
  17. package/src/tests/tool_validation.test.ts +2 -2
  18. package/src/tool/jpgToPdf/bibliography.astro +11 -0
  19. package/src/tool/jpgToPdf/bibliography.ts +12 -0
  20. package/src/tool/jpgToPdf/component.astro +10 -0
  21. package/src/tool/jpgToPdf/entry.ts +26 -0
  22. package/src/tool/jpgToPdf/i18n/de.ts +111 -0
  23. package/src/tool/jpgToPdf/i18n/en.ts +37 -0
  24. package/src/tool/jpgToPdf/i18n/es.ts +111 -0
  25. package/src/tool/jpgToPdf/i18n/fr.ts +111 -0
  26. package/src/tool/jpgToPdf/i18n/id.ts +111 -0
  27. package/src/tool/jpgToPdf/i18n/it.ts +111 -0
  28. package/src/tool/jpgToPdf/i18n/ja.ts +111 -0
  29. package/src/tool/jpgToPdf/i18n/ko.ts +111 -0
  30. package/src/tool/jpgToPdf/i18n/nl.ts +111 -0
  31. package/src/tool/jpgToPdf/i18n/pl.ts +111 -0
  32. package/src/tool/jpgToPdf/i18n/pt.ts +111 -0
  33. package/src/tool/jpgToPdf/i18n/ru.ts +111 -0
  34. package/src/tool/jpgToPdf/i18n/sv.ts +111 -0
  35. package/src/tool/jpgToPdf/i18n/tr.ts +111 -0
  36. package/src/tool/jpgToPdf/i18n/zh.ts +111 -0
  37. package/src/tool/jpgToPdf/index.ts +11 -0
  38. package/src/tool/jpgToPdf/jpg-to-pdf-converter.css +3 -0
  39. package/src/tool/jpgToPdf/seo.astro +12 -0
  40. package/src/tool/pngAJpg/i18n/ko.ts +1 -1
  41. package/src/tool/pngAJpg/i18n/ru.ts +1 -1
  42. package/src/tool/pngAJpg/i18n/zh.ts +1 -1
  43. package/src/tool/pngToPdf/bibliography.astro +11 -0
  44. package/src/tool/pngToPdf/bibliography.ts +12 -0
  45. package/src/tool/pngToPdf/component.astro +10 -0
  46. package/src/tool/pngToPdf/entry.ts +26 -0
  47. package/src/tool/pngToPdf/i18n/de.ts +111 -0
  48. package/src/tool/pngToPdf/i18n/en.ts +37 -0
  49. package/src/tool/pngToPdf/i18n/es.ts +111 -0
  50. package/src/tool/pngToPdf/i18n/fr.ts +111 -0
  51. package/src/tool/pngToPdf/i18n/id.ts +111 -0
  52. package/src/tool/pngToPdf/i18n/it.ts +111 -0
  53. package/src/tool/pngToPdf/i18n/ja.ts +111 -0
  54. package/src/tool/pngToPdf/i18n/ko.ts +111 -0
  55. package/src/tool/pngToPdf/i18n/nl.ts +111 -0
  56. package/src/tool/pngToPdf/i18n/pl.ts +111 -0
  57. package/src/tool/pngToPdf/i18n/pt.ts +111 -0
  58. package/src/tool/pngToPdf/i18n/ru.ts +111 -0
  59. package/src/tool/pngToPdf/i18n/sv.ts +111 -0
  60. package/src/tool/pngToPdf/i18n/tr.ts +111 -0
  61. package/src/tool/pngToPdf/i18n/zh.ts +111 -0
  62. package/src/tool/pngToPdf/index.ts +11 -0
  63. package/src/tool/pngToPdf/png-to-pdf-converter.css +3 -0
  64. package/src/tool/pngToPdf/seo.astro +12 -0
  65. package/src/tool/webpToPdf/bibliography.astro +11 -0
  66. package/src/tool/webpToPdf/bibliography.ts +12 -0
  67. package/src/tool/webpToPdf/component.astro +10 -0
  68. package/src/tool/webpToPdf/entry.ts +26 -0
  69. package/src/tool/webpToPdf/i18n/de.ts +111 -0
  70. package/src/tool/webpToPdf/i18n/en.ts +37 -0
  71. package/src/tool/webpToPdf/i18n/es.ts +111 -0
  72. package/src/tool/webpToPdf/i18n/fr.ts +111 -0
  73. package/src/tool/webpToPdf/i18n/id.ts +111 -0
  74. package/src/tool/webpToPdf/i18n/it.ts +111 -0
  75. package/src/tool/webpToPdf/i18n/ja.ts +111 -0
  76. package/src/tool/webpToPdf/i18n/ko.ts +111 -0
  77. package/src/tool/webpToPdf/i18n/nl.ts +111 -0
  78. package/src/tool/webpToPdf/i18n/pl.ts +111 -0
  79. package/src/tool/webpToPdf/i18n/pt.ts +111 -0
  80. package/src/tool/webpToPdf/i18n/ru.ts +111 -0
  81. package/src/tool/webpToPdf/i18n/sv.ts +111 -0
  82. package/src/tool/webpToPdf/i18n/tr.ts +111 -0
  83. package/src/tool/webpToPdf/i18n/zh.ts +111 -0
  84. package/src/tool/webpToPdf/index.ts +11 -0
  85. package/src/tool/webpToPdf/seo.astro +12 -0
  86. package/src/tool/webpToPdf/webp-to-pdf-converter.css +3 -0
  87. package/src/tools.ts +9 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-converters",
3
- "version": "1.23.0",
3
+ "version": "1.24.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -20,6 +20,9 @@ import { gifAWebp } from '../tool/gifAWebp/entry';
20
20
  import { pngAIco } from '../tool/pngAIco/entry';
21
21
  import { jpgAIco } from '../tool/jpgAIco/entry';
22
22
  import { webpAIco } from '../tool/webpAIco/entry';
23
+ import { jpgToPdf } from '../tool/jpgToPdf/entry';
24
+ import { pngToPdf } from '../tool/pngToPdf/entry';
25
+ import { webpToPdf } from '../tool/webpToPdf/entry';
23
26
 
24
27
  export const convertersCategory: ConvertersCategoryEntry = {
25
28
  icon: 'mdi:image-sync',
@@ -30,6 +33,7 @@ export const convertersCategory: ConvertersCategoryEntry = {
30
33
  avifAJpg, avifAPng, avifAWebp,
31
34
  gifAJpg, gifAPng, gifAWebp,
32
35
  pngAIco, jpgAIco, webpAIco,
36
+ jpgToPdf, pngToPdf, webpToPdf,
33
37
  ],
34
38
  i18n: {
35
39
  de: () => import('./i18n/de').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -62,4 +62,7 @@ import { webpAIco } from './tool/webpAIco/entry';
62
62
  import { webpAJpg } from './tool/webpAJpg/entry';
63
63
  import { webpAPng } from './tool/webpAPng/entry';
64
64
  import { imagenBase64 } from './tool/imagenBase64/entry';
65
- export const ALL_ENTRIES = [avifAJpg, avifAPng, avifAWebp, bmpAJpg, bmpAPng, bmpAWebp, gifAJpg, gifAPng, gifAWebp, imagenBase64, jpgAIco, jpgAPng, jpgAWebp, pngAIco, pngAJpg, pngAWebp, svgAJpg, svgAPng, webpAIco, webpAJpg, webpAPng];
65
+ import { jpgToPdf } from './tool/jpgToPdf/entry';
66
+ import { pngToPdf } from './tool/pngToPdf/entry';
67
+ import { webpToPdf } from './tool/webpToPdf/entry';
68
+ export const ALL_ENTRIES = [avifAJpg, avifAPng, avifAWebp, bmpAJpg, bmpAPng, bmpAWebp, gifAJpg, gifAPng, gifAWebp, imagenBase64, jpgAIco, jpgAPng, jpgAWebp, pngAIco, pngAJpg, pngAWebp, svgAJpg, svgAPng, webpAIco, webpAJpg, webpAPng, jpgToPdf, pngToPdf, webpToPdf];
package/src/index.ts CHANGED
@@ -19,6 +19,9 @@ export * from './tool/gifAWebp';
19
19
  export * from './tool/pngAIco';
20
20
  export * from './tool/jpgAIco';
21
21
  export * from './tool/webpAIco';
22
+ export * from './tool/jpgToPdf';
23
+ export * from './tool/pngToPdf';
24
+ export * from './tool/webpToPdf';
22
25
 
23
26
  export { convertersCategory } from './category';
24
27
  export const ConvertersCategorySEO = () => import('./category/seo.astro').then((m) => m.default);
@@ -0,0 +1,80 @@
1
+ ---
2
+ import type { ImageToPdfUI, SourceImageFormat } from './image-to-pdf/ui';
3
+
4
+ interface Props {
5
+ ui: ImageToPdfUI;
6
+ sourceFormat: SourceImageFormat;
7
+ }
8
+
9
+ const { ui, sourceFormat } = Astro.props;
10
+ const accept = sourceFormat === 'jpg' ? '.jpg,.jpeg,image/jpeg' : `.${sourceFormat},image/${sourceFormat}`;
11
+ ---
12
+
13
+ <div class:list={["pdf-converter", `pdf-converter-${sourceFormat}`]} data-source-format={sourceFormat} data-ui={JSON.stringify(ui)}>
14
+ <section class="pdf-converter-stage" id="pdf-dropzone">
15
+ <span class="pdf-converter-mark" aria-hidden="true">PDF</span>
16
+ <h2>{ui.dropTitle}</h2>
17
+ <p>{ui.dropText}</p>
18
+ <p class="pdf-converter-privacy">{ui.privacyNote}</p>
19
+ <button class="pdf-converter-select" type="button">{ui.selectFiles}</button>
20
+ <input class="pdf-file-input" id="pdf-file-input" type="file" accept={accept} multiple hidden />
21
+ </section>
22
+
23
+ <section class="pdf-converter-workspace" id="pdf-workspace" hidden>
24
+ <div class="pdf-converter-toolbar">
25
+ <div>
26
+ <h2>{ui.optionsTitle}</h2>
27
+ <p><span id="pdf-page-size-value"></span> · <span id="pdf-orientation-value"></span></p>
28
+ </div>
29
+ <button class="pdf-converter-secondary" id="pdf-reset" type="button">{ui.reset}</button>
30
+ </div>
31
+
32
+ <div class="pdf-converter-content">
33
+ <section class="pdf-converter-options" aria-labelledby="pdf-options-title">
34
+ <h3 id="pdf-options-title">{ui.settingsTitle}</h3>
35
+ <div class="pdf-converter-option-line">
36
+ <span class="pdf-converter-option-label">{ui.pageSizeLabel}</span>
37
+ <div class="pdf-converter-choices">
38
+ <button class="pdf-converter-choice" type="button" data-page-size="a4" aria-pressed="false">{ui.a4}</button>
39
+ <button class="pdf-converter-choice" type="button" data-page-size="letter" aria-pressed="false">{ui.letter}</button>
40
+ </div>
41
+ </div>
42
+ <div class="pdf-converter-option-line">
43
+ <span class="pdf-converter-option-label">{ui.orientationLabel}</span>
44
+ <div class="pdf-converter-choices">
45
+ <button class="pdf-converter-choice" type="button" data-orientation="portrait" aria-pressed="false">{ui.portrait}</button>
46
+ <button class="pdf-converter-choice" type="button" data-orientation="landscape" aria-pressed="false">{ui.landscape}</button>
47
+ </div>
48
+ </div>
49
+ <div class="pdf-converter-option-line">
50
+ <div class="pdf-converter-toolbar"><span class="pdf-converter-option-label">{ui.marginLabel}</span><strong class="pdf-converter-value" id="pdf-margin-value"></strong></div>
51
+ <input class="pdf-converter-range" id="pdf-margin" type="range" min="0" max="32" step="1" value="12" aria-label={ui.marginLabel} />
52
+ </div>
53
+ </section>
54
+
55
+ <section class="pdf-converter-pages" aria-labelledby="pdf-pages-title">
56
+ <div class="pdf-converter-pages-header">
57
+ <div>
58
+ <h3 id="pdf-pages-title">{ui.pagesTitle}</h3>
59
+ <p>{ui.reorderHint}</p>
60
+ </div>
61
+ </div>
62
+ <ol class="pdf-pages" id="pdf-page-list"><li class="pdf-pages-empty">{ui.emptyPages}</li></ol>
63
+ </section>
64
+ </div>
65
+
66
+ <div class="pdf-converter-footer">
67
+ <span class="pdf-converter-status" id="pdf-status" data-tone="neutral">{ui.ready}</span>
68
+ <div class="pdf-converter-actions">
69
+ <button class="pdf-converter-primary" id="pdf-create" type="button" disabled>{ui.createPdf}</button>
70
+ <a class="pdf-converter-download" id="pdf-download" href="#" hidden>{ui.downloadPdf}</a>
71
+ </div>
72
+ </div>
73
+ </section>
74
+ </div>
75
+
76
+ <script>
77
+ import { mountImageToPdf } from './image-to-pdf/controller';
78
+
79
+ document.querySelectorAll<HTMLElement>('.pdf-converter').forEach(mountImageToPdf);
80
+ </script>
@@ -0,0 +1,173 @@
1
+ import { canCreatePdf, type PageStatus } from './evaluator';
2
+ import { buildPdf, encodeImage, getPdfFileName, isSupportedFile, type Orientation, type PageSize, type PdfPageImage } from './logic';
3
+ import { renderOptions, renderPages, setStatus, setWorkspace, type PageViewModel } from './dom-views';
4
+ import { loadPdfOptions, savePdfOptions, type StoredPdfOptions } from './storage';
5
+ import type { ImageToPdfUI, SourceImageFormat } from './ui';
6
+
7
+ interface PageState extends PageViewModel, PdfPageImage {
8
+ status: PageStatus;
9
+ }
10
+
11
+ interface ConverterState {
12
+ pages: PageState[];
13
+ options: StoredPdfOptions;
14
+ outputUrl: string;
15
+ }
16
+
17
+ interface AddFilesParams {
18
+ root: HTMLElement;
19
+ state: ConverterState;
20
+ files: File[];
21
+ source: SourceImageFormat;
22
+ ui: ImageToPdfUI;
23
+ }
24
+
25
+ function parseUi(root: HTMLElement): ImageToPdfUI {
26
+ return JSON.parse(root.dataset.ui ?? '{}') as ImageToPdfUI;
27
+ }
28
+
29
+ function getSource(root: HTMLElement): SourceImageFormat {
30
+ return (root.dataset.sourceFormat ?? 'jpg') as SourceImageFormat;
31
+ }
32
+
33
+ function resetOutput(root: HTMLElement, state: ConverterState): void {
34
+ if (state.outputUrl) URL.revokeObjectURL(state.outputUrl);
35
+ state.outputUrl = '';
36
+ const download = root.querySelector<HTMLAnchorElement>('#pdf-download');
37
+ download?.toggleAttribute('hidden', true);
38
+ }
39
+
40
+ function syncPages(root: HTMLElement, state: ConverterState, ui: ImageToPdfUI): void {
41
+ const list = root.querySelector<HTMLOListElement>('#pdf-page-list');
42
+ if (!list) return;
43
+ renderPages(list, state.pages, ui);
44
+ setWorkspace(root, state.pages.length > 0);
45
+ const create = root.querySelector<HTMLButtonElement>('#pdf-create');
46
+ if (create) create.disabled = !canCreatePdf(state.pages.map((page) => page.status));
47
+ }
48
+
49
+ function createPage(file: File, previewUrl: string): PageState {
50
+ return { id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, name: file.name, previewUrl, status: 'processing', width: 0, height: 0, jpegBytes: new Uint8Array() };
51
+ }
52
+
53
+ async function processPage(page: PageState, file: File, source: SourceImageFormat): Promise<void> {
54
+ const encoded = await encodeImage(file, source);
55
+ page.width = encoded.width;
56
+ page.height = encoded.height;
57
+ page.jpegBytes = encoded.jpegBytes;
58
+ page.status = 'ready';
59
+ }
60
+
61
+ async function addFiles({ root, state, files, source, ui }: AddFilesParams): Promise<void> {
62
+ for (const file of files) {
63
+ if (!isSupportedFile(file, source)) {
64
+ setStatus(root, ui.invalidFile, 'error');
65
+ continue;
66
+ }
67
+ const page = createPage(file, URL.createObjectURL(file));
68
+ state.pages.push(page);
69
+ syncPages(root, state, ui);
70
+ try {
71
+ await processPage(page, file, source);
72
+ } catch {
73
+ page.status = 'error';
74
+ page.error = ui.error;
75
+ }
76
+ syncPages(root, state, ui);
77
+ }
78
+ const pageWord = state.pages.length === 1 ? ui.pageSingular : ui.pagePlural;
79
+ setStatus(root, `${state.pages.length} ${pageWord}`, 'neutral');
80
+ }
81
+
82
+ function movePage(state: ConverterState, id: string, direction: number): void {
83
+ const index = state.pages.findIndex((page) => page.id === id);
84
+ const next = index + direction;
85
+ if (index < 0 || next < 0 || next >= state.pages.length) return;
86
+ const [page] = state.pages.splice(index, 1);
87
+ if (page) state.pages.splice(next, 0, page);
88
+ }
89
+
90
+ function removePage(state: ConverterState, id: string): void {
91
+ const index = state.pages.findIndex((page) => page.id === id);
92
+ if (index < 0) return;
93
+ const [page] = state.pages.splice(index, 1);
94
+ if (page) URL.revokeObjectURL(page.previewUrl);
95
+ }
96
+
97
+ function updateOptions(root: HTMLElement, state: ConverterState, ui: ImageToPdfUI): void {
98
+ renderOptions(root, state.options, ui);
99
+ savePdfOptions(state.options);
100
+ resetOutput(root, state);
101
+ }
102
+
103
+ function createPdf(root: HTMLElement, state: ConverterState, ui: ImageToPdfUI, source: SourceImageFormat): void {
104
+ if (!canCreatePdf(state.pages.map((page) => page.status))) return;
105
+ setStatus(root, ui.building, 'neutral');
106
+ const bytes = buildPdf(state.pages, state.options);
107
+ const pdfBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
108
+ state.outputUrl = URL.createObjectURL(new Blob([pdfBuffer], { type: 'application/pdf' }));
109
+ const download = root.querySelector<HTMLAnchorElement>('#pdf-download');
110
+ if (!download) return;
111
+ download.href = state.outputUrl;
112
+ download.download = getPdfFileName(source);
113
+ download.textContent = ui.downloadPdf;
114
+ download.toggleAttribute('hidden', false);
115
+ setStatus(root, ui.pdfReady, 'ready');
116
+ }
117
+
118
+ function resetConverter(root: HTMLElement, state: ConverterState, ui: ImageToPdfUI): void {
119
+ state.pages.forEach((page) => URL.revokeObjectURL(page.previewUrl));
120
+ state.pages = [];
121
+ resetOutput(root, state);
122
+ syncPages(root, state, ui);
123
+ setStatus(root, ui.ready, 'neutral');
124
+ }
125
+
126
+ function bindFileInput(root: HTMLElement, state: ConverterState, source: SourceImageFormat, ui: ImageToPdfUI): void {
127
+ const input = root.querySelector<HTMLInputElement>('#pdf-file-input');
128
+ const dropzone = root.querySelector<HTMLElement>('#pdf-dropzone');
129
+ dropzone?.addEventListener('click', () => input?.click());
130
+ dropzone?.addEventListener('dragover', (event) => { event.preventDefault(); dropzone.classList.add('is-dragging'); });
131
+ dropzone?.addEventListener('dragleave', () => dropzone.classList.remove('is-dragging'));
132
+ dropzone?.addEventListener('drop', (event) => { event.preventDefault(); dropzone.classList.remove('is-dragging'); void addFiles({ root, state, files: [...(event.dataTransfer?.files ?? [])], source, ui }); });
133
+ input?.addEventListener('change', () => { void addFiles({ root, state, files: [...(input.files ?? [])], source, ui }); input.value = ''; });
134
+ }
135
+
136
+ function bindOptions(root: HTMLElement, state: ConverterState, ui: ImageToPdfUI): void {
137
+ root.querySelectorAll<HTMLButtonElement>('[data-page-size]').forEach((button) => button.addEventListener('click', () => { state.options.pageSize = button.dataset.pageSize as PageSize; updateOptions(root, state, ui); }));
138
+ root.querySelectorAll<HTMLButtonElement>('[data-orientation]').forEach((button) => button.addEventListener('click', () => { state.options.orientation = button.dataset.orientation as Orientation; updateOptions(root, state, ui); }));
139
+ root.querySelector<HTMLInputElement>('#pdf-margin')?.addEventListener('input', (event) => { state.options.marginMm = Number((event.target as HTMLInputElement).value); updateOptions(root, state, ui); });
140
+ }
141
+
142
+ function handlePageAction(root: HTMLElement, state: ConverterState, ui: ImageToPdfUI, event: Event): void {
143
+ const target = event.target as HTMLElement;
144
+ const item = target.closest<HTMLElement>('[data-page-id]');
145
+ if (!item) return;
146
+ const pageId = item.dataset.pageId ?? '';
147
+ switch (target.dataset.action) {
148
+ case 'up': movePage(state, pageId, -1); break;
149
+ case 'down': movePage(state, pageId, 1); break;
150
+ case 'remove': removePage(state, pageId); break;
151
+ default: return;
152
+ }
153
+ syncPages(root, state, ui);
154
+ resetOutput(root, state);
155
+ }
156
+
157
+ function bindPageActions(root: HTMLElement, state: ConverterState, ui: ImageToPdfUI): void {
158
+ const list = root.querySelector('#pdf-page-list');
159
+ if (!list) return;
160
+ list.addEventListener('click', (event) => handlePageAction(root, state, ui, event));
161
+ }
162
+
163
+ export function mountImageToPdf(root: HTMLElement): void {
164
+ const ui = parseUi(root);
165
+ const source = getSource(root);
166
+ const state: ConverterState = { pages: [], options: loadPdfOptions(), outputUrl: '' };
167
+ renderOptions(root, state.options, ui);
168
+ bindFileInput(root, state, source, ui);
169
+ bindOptions(root, state, ui);
170
+ bindPageActions(root, state, ui);
171
+ root.querySelector('#pdf-create')?.addEventListener('click', () => createPdf(root, state, ui, source));
172
+ root.querySelector('#pdf-reset')?.addEventListener('click', () => resetConverter(root, state, ui));
173
+ }
@@ -0,0 +1,55 @@
1
+ import { statusLabel, type PageStatus } from './evaluator';
2
+ import type { ImageToPdfUI } from './ui';
3
+
4
+ export interface PageViewModel {
5
+ id: string;
6
+ name: string;
7
+ previewUrl: string;
8
+ status: PageStatus;
9
+ error?: string;
10
+ }
11
+
12
+ function actionButton(label: string, action: string, disabled: boolean): string {
13
+ return `<button type="button" class="pdf-page-action" data-action="${action}" aria-label="${label}"${disabled ? ' disabled' : ''}>${label}</button>`;
14
+ }
15
+
16
+ function pageMarkup(page: PageViewModel, index: number, total: number, ui: ImageToPdfUI): string {
17
+ const status = statusLabel(page.status, ui);
18
+ const error = page.error ? `<span class="pdf-page-error">${page.error}</span>` : '';
19
+ return `<li class="pdf-page" data-page-id="${page.id}"><span class="pdf-page-number">${index + 1}</span><img class="pdf-page-thumb" src="${page.previewUrl}" alt="${page.name}"><span class="pdf-page-meta"><strong>${page.name}</strong><span>${ui.page} ${index + 1} · ${status}</span>${error}</span><span class="pdf-page-actions">${actionButton(ui.moveUp, 'up', index === 0)}${actionButton(ui.moveDown, 'down', index === total - 1)}${actionButton(ui.remove, 'remove', false)}</span></li>`;
20
+ }
21
+
22
+ export function renderPages(list: HTMLOListElement, pages: PageViewModel[], ui: ImageToPdfUI): void {
23
+ list.innerHTML = pages.length ? pages.map((page, index) => pageMarkup(page, index, pages.length, ui)).join('') : `<li class="pdf-pages-empty">${ui.emptyPages}</li>`;
24
+ }
25
+
26
+ export function renderOptions(root: HTMLElement, options: { pageSize: string; orientation: string; marginMm: number }, ui: ImageToPdfUI): void {
27
+ root.querySelectorAll<HTMLButtonElement>('[data-page-size]').forEach((button) => {
28
+ button.classList.toggle('is-active', button.dataset.pageSize === options.pageSize);
29
+ button.setAttribute('aria-pressed', String(button.dataset.pageSize === options.pageSize));
30
+ });
31
+ root.querySelectorAll<HTMLButtonElement>('[data-orientation]').forEach((button) => {
32
+ button.classList.toggle('is-active', button.dataset.orientation === options.orientation);
33
+ button.setAttribute('aria-pressed', String(button.dataset.orientation === options.orientation));
34
+ });
35
+ const margin = root.querySelector<HTMLInputElement>('#pdf-margin');
36
+ const marginValue = root.querySelector<HTMLElement>('#pdf-margin-value');
37
+ if (margin) margin.value = String(options.marginMm);
38
+ if (marginValue) marginValue.textContent = `${options.marginMm} mm`;
39
+ const pageSize = root.querySelector<HTMLElement>('#pdf-page-size-value');
40
+ const orientation = root.querySelector<HTMLElement>('#pdf-orientation-value');
41
+ if (pageSize) pageSize.textContent = options.pageSize === 'a4' ? ui.a4 : ui.letter;
42
+ if (orientation) orientation.textContent = options.orientation === 'portrait' ? ui.portrait : ui.landscape;
43
+ }
44
+
45
+ export function setWorkspace(root: HTMLElement, visible: boolean): void {
46
+ const workspace = root.querySelector<HTMLElement>('#pdf-workspace');
47
+ workspace?.toggleAttribute('hidden', !visible);
48
+ }
49
+
50
+ export function setStatus(root: HTMLElement, text: string, tone: string): void {
51
+ const status = root.querySelector<HTMLElement>('#pdf-status');
52
+ if (!status) return;
53
+ status.textContent = text;
54
+ status.dataset.tone = tone;
55
+ }
@@ -0,0 +1,18 @@
1
+ import type { ImageToPdfUI } from './ui';
2
+
3
+ export type PageStatus = 'processing' | 'ready' | 'error';
4
+
5
+ export interface EvaluatedPage {
6
+ status: PageStatus;
7
+ error?: string;
8
+ }
9
+
10
+ export function canCreatePdf(statuses: PageStatus[]): boolean {
11
+ return statuses.length > 0 && statuses.every((status) => status === 'ready');
12
+ }
13
+
14
+ export function statusLabel(status: PageStatus, ui: ImageToPdfUI): string {
15
+ if (status === 'ready') return ui.ready;
16
+ if (status === 'processing') return ui.processing;
17
+ return ui.error;
18
+ }
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildPdf, fitImageInPage, getMarginPoints, getPageDimensions } from './logic';
3
+
4
+ const image = { id: 'one', name: 'one.jpg', width: 1200, height: 800, jpegBytes: new Uint8Array([255, 216, 255, 217]) };
5
+
6
+ describe('image to PDF logic', () => {
7
+ it('calculates paper dimensions and margins', () => {
8
+ expect(getPageDimensions({ pageSize: 'a4', orientation: 'portrait', marginMm: 12 })).toEqual({ width: 595.28, height: 841.89 });
9
+ expect(getPageDimensions({ pageSize: 'letter', orientation: 'landscape', marginMm: 12 })).toEqual({ width: 792, height: 612 });
10
+ expect(getMarginPoints(25.4)).toBe(72);
11
+ });
12
+
13
+ it('fits an image inside the printable area', () => {
14
+ const fitted = fitImageInPage(image, { pageSize: 'a4', orientation: 'portrait', marginMm: 12 });
15
+ expect(fitted.width).toBeLessThan(595.28);
16
+ expect(fitted.height).toBeLessThan(841.89);
17
+ expect(fitted.x).toBeGreaterThan(0);
18
+ expect(fitted.y).toBeGreaterThan(0);
19
+ });
20
+
21
+ it('builds a PDF with one page and an embedded JPEG stream', () => {
22
+ const pdf = new TextDecoder().decode(buildPdf([image], { pageSize: 'a4', orientation: 'portrait', marginMm: 12 }));
23
+ expect(pdf).toContain('%PDF-1.4');
24
+ expect(pdf).toContain('/Type /Page');
25
+ expect(pdf).toContain('/Filter /DCTDecode');
26
+ expect(pdf).toContain('/Count 1');
27
+ expect(pdf).toContain('%%EOF');
28
+ });
29
+ });
@@ -0,0 +1,214 @@
1
+ import type { SourceImageFormat } from './ui';
2
+
3
+ export type PageSize = 'a4' | 'letter';
4
+ export type Orientation = 'portrait' | 'landscape';
5
+
6
+ export interface PdfOptions {
7
+ pageSize: PageSize;
8
+ orientation: Orientation;
9
+ marginMm: number;
10
+ }
11
+
12
+ export interface PdfPageImage {
13
+ id: string;
14
+ name: string;
15
+ width: number;
16
+ height: number;
17
+ jpegBytes: Uint8Array;
18
+ }
19
+
20
+ interface PageDimensions {
21
+ width: number;
22
+ height: number;
23
+ }
24
+
25
+ interface DrawnImage extends PageDimensions {
26
+ x: number;
27
+ y: number;
28
+ }
29
+
30
+ const MM_TO_POINTS = 72 / 25.4;
31
+ const PAGE_DIMENSIONS: Record<PageSize, PageDimensions> = {
32
+ a4: { width: 595.28, height: 841.89 },
33
+ letter: { width: 612, height: 792 },
34
+ };
35
+
36
+ export function getPageDimensions(options: PdfOptions): PageDimensions {
37
+ const dimensions = PAGE_DIMENSIONS[options.pageSize];
38
+ const isLandscape = options.orientation === 'landscape';
39
+ return isLandscape
40
+ ? { width: dimensions.height, height: dimensions.width }
41
+ : { ...dimensions };
42
+ }
43
+
44
+ export function getMarginPoints(marginMm: number): number {
45
+ return Math.max(0, marginMm) * MM_TO_POINTS;
46
+ }
47
+
48
+ export function fitImageInPage(
49
+ image: Pick<PdfPageImage, 'width' | 'height'>,
50
+ options: PdfOptions,
51
+ ): DrawnImage {
52
+ const page = getPageDimensions(options);
53
+ const margin = getMarginPoints(options.marginMm);
54
+ const availableWidth = Math.max(1, page.width - margin * 2);
55
+ const availableHeight = Math.max(1, page.height - margin * 2);
56
+ const scale = Math.min(availableWidth / image.width, availableHeight / image.height);
57
+ const width = image.width * scale;
58
+ const height = image.height * scale;
59
+ return {
60
+ width,
61
+ height,
62
+ x: (page.width - width) / 2,
63
+ y: (page.height - height) / 2,
64
+ };
65
+ }
66
+
67
+ export function isSupportedFile(file: File, source: SourceImageFormat): boolean {
68
+ const extension = file.name.split('.').pop()?.toLowerCase();
69
+ const allowed = source === 'jpg' ? ['jpg', 'jpeg'] : [source];
70
+ return allowed.includes(extension ?? '') || file.type === `image/${source === 'jpg' ? 'jpeg' : source}`;
71
+ }
72
+
73
+ export function getPdfFileName(source: SourceImageFormat): string {
74
+ return `${source}-images.pdf`;
75
+ }
76
+
77
+ function textBytes(value: string): Uint8Array {
78
+ return new TextEncoder().encode(value);
79
+ }
80
+
81
+ function concatBytes(chunks: Uint8Array[]): Uint8Array {
82
+ const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
83
+ const result = new Uint8Array(total);
84
+ let offset = 0;
85
+ chunks.forEach((chunk) => {
86
+ result.set(chunk, offset);
87
+ offset += chunk.length;
88
+ });
89
+ return result;
90
+ }
91
+
92
+ function streamObject(dictionary: string, stream: Uint8Array): Uint8Array {
93
+ return concatBytes([textBytes(`${dictionary}\nstream\n`), stream, textBytes('\nendstream')]);
94
+ }
95
+
96
+ function makePageObjects(page: PdfPageImage, index: number, options: PdfOptions): { pageObject: Uint8Array; imageObject: Uint8Array; contentObject: Uint8Array; pageNumber: number } {
97
+ const pageNumber = 3 + index * 3;
98
+ const imageNumber = pageNumber + 1;
99
+ const contentNumber = pageNumber + 2;
100
+ const imageDictionary = `<< /Type /XObject /Subtype /Image /Width ${page.width} /Height ${page.height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${page.jpegBytes.length} >>`;
101
+ const imageObject = streamObject(imageDictionary, page.jpegBytes);
102
+ const drawn = fitImageInPage(page, options);
103
+ const content = textBytes(`q\n${drawn.width.toFixed(2)} 0 0 ${drawn.height.toFixed(2)} ${drawn.x.toFixed(2)} ${drawn.y.toFixed(2)} cm\n/Im${index} Do\nQ`);
104
+ const pageBody = `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${getPageDimensions(options).width} ${getPageDimensions(options).height}] /Resources << /XObject << /Im${index} ${imageNumber} 0 R >> >> /Contents ${contentNumber} 0 R >>`;
105
+ return {
106
+ pageObject: textBytes(pageBody),
107
+ imageObject,
108
+ contentObject: streamObject(`<< /Length ${content.length} >>`, content),
109
+ pageNumber,
110
+ };
111
+ }
112
+
113
+ function serializeObjects(objects: Map<number, Uint8Array>, maxObject: number): Uint8Array {
114
+ const chunks: Uint8Array[] = [new Uint8Array([37, 80, 68, 70, 45, 49, 46, 52, 10, 37, 255, 255, 255, 255, 10])];
115
+ const offsets = new Array<number>(maxObject + 1).fill(0);
116
+ let offset = chunks[0]?.length ?? 0;
117
+ for (let number = 1; number <= maxObject; number += 1) {
118
+ const object = objects.get(number) ?? new Uint8Array();
119
+ offsets[number] = offset;
120
+ const chunk = concatBytes([textBytes(`${number} 0 obj\n`), object, textBytes('\nendobj\n')]);
121
+ chunks.push(chunk);
122
+ offset += chunk.length;
123
+ }
124
+ const xrefOffset = offset;
125
+ const xref = [`xref\n0 ${maxObject + 1}\n0000000000 65535 f \n`];
126
+ for (let number = 1; number <= maxObject; number += 1) {
127
+ xref.push(`${String(offsets[number]).padStart(10, '0')} 00000 n \n`);
128
+ }
129
+ chunks.push(textBytes(xref.join('')));
130
+ chunks.push(textBytes(`trailer\n<< /Size ${maxObject + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`));
131
+ return concatBytes(chunks);
132
+ }
133
+
134
+ export function buildPdf(pages: PdfPageImage[], options: PdfOptions): Uint8Array {
135
+ const objects = new Map<number, Uint8Array>();
136
+ const pageNumbers: number[] = [];
137
+ pages.forEach((page, index) => {
138
+ const created = makePageObjects(page, index, options);
139
+ pageNumbers.push(created.pageNumber);
140
+ objects.set(created.pageNumber, created.pageObject);
141
+ objects.set(created.pageNumber + 1, created.imageObject);
142
+ objects.set(created.pageNumber + 2, created.contentObject);
143
+ });
144
+ objects.set(2, textBytes(`<< /Type /Pages /Kids [${pageNumbers.map((number) => `${number} 0 R`).join(' ')}] /Count ${pages.length} >>`));
145
+ objects.set(1, textBytes('<< /Type /Catalog /Pages 2 0 R >>'));
146
+ return serializeObjects(objects, Math.max(2, 2 + pages.length * 3));
147
+ }
148
+
149
+ async function loadBitmap(file: File): Promise<{ bitmap: ImageBitmap | HTMLImageElement; width: number; height: number }> {
150
+ if ('createImageBitmap' in window) {
151
+ const bitmap = await createImageBitmap(file);
152
+ return { bitmap, width: bitmap.width, height: bitmap.height };
153
+ }
154
+ const url = URL.createObjectURL(file);
155
+ const image = await new Promise<HTMLImageElement>((resolve, reject) => {
156
+ const element = new Image();
157
+ element.onload = () => resolve(element);
158
+ element.onerror = () => reject(new Error('Image could not be read'));
159
+ element.src = url;
160
+ });
161
+ URL.revokeObjectURL(url);
162
+ return { bitmap: image, width: image.naturalWidth, height: image.naturalHeight };
163
+ }
164
+
165
+ async function canvasJpeg(file: File): Promise<{ bytes: Uint8Array; width: number; height: number }> {
166
+ const loaded = await loadBitmap(file);
167
+ const canvas = document.createElement('canvas');
168
+ canvas.width = loaded.width;
169
+ canvas.height = loaded.height;
170
+ const context = canvas.getContext('2d');
171
+ if (!context) throw new Error('Canvas is unavailable');
172
+ context.fillStyle = '#fff';
173
+ context.fillRect(0, 0, canvas.width, canvas.height);
174
+ context.drawImage(loaded.bitmap, 0, 0);
175
+ const blob = await new Promise<Blob>((resolve, reject) => canvas.toBlob((value) => value ? resolve(value) : reject(new Error('JPEG export failed')), 'image/jpeg', 0.94));
176
+ if ('close' in loaded.bitmap) loaded.bitmap.close();
177
+ return { bytes: new Uint8Array(await blob.arrayBuffer()), width: loaded.width, height: loaded.height };
178
+ }
179
+
180
+ function readJpegSegment(bytes: Uint8Array, offset: number): { marker: number; length: number } {
181
+ return { marker: bytes[offset + 1]!, length: (bytes[offset + 2]! << 8) + bytes[offset + 3]! };
182
+ }
183
+
184
+ function isFrameMarker(marker: number): boolean {
185
+ return marker >= 0xc0 && marker <= 0xc3;
186
+ }
187
+
188
+ function readFrameDimensions(bytes: Uint8Array, offset: number): { width: number; height: number } {
189
+ return { height: (bytes[offset + 5]! << 8) + bytes[offset + 6]!, width: (bytes[offset + 7]! << 8) + bytes[offset + 8]! };
190
+ }
191
+
192
+ function readJpegDimensions(bytes: Uint8Array): { width: number; height: number } {
193
+ let offset = 2;
194
+ while (offset + 9 < bytes.length) {
195
+ if (bytes[offset] !== 0xff) {
196
+ offset += 1;
197
+ continue;
198
+ }
199
+ const segment = readJpegSegment(bytes, offset);
200
+ if (isFrameMarker(segment.marker)) return readFrameDimensions(bytes, offset);
201
+ offset += segment.length + 2;
202
+ }
203
+ throw new Error('JPEG dimensions are unavailable');
204
+ }
205
+
206
+ export async function encodeImage(file: File, source: SourceImageFormat): Promise<Omit<PdfPageImage, 'id' | 'name'>> {
207
+ if (source === 'jpg') {
208
+ const bytes = new Uint8Array(await file.arrayBuffer());
209
+ const dimensions = readJpegDimensions(bytes);
210
+ return { jpegBytes: bytes, ...dimensions };
211
+ }
212
+ const encoded = await canvasJpeg(file);
213
+ return { jpegBytes: encoded.bytes, width: encoded.width, height: encoded.height };
214
+ }
@@ -0,0 +1,36 @@
1
+ import type { Orientation, PageSize } from './logic';
2
+
3
+ export interface StoredPdfOptions {
4
+ pageSize: PageSize;
5
+ orientation: Orientation;
6
+ marginMm: number;
7
+ }
8
+
9
+ const STORAGE_KEY = 'jjlmoya-image-to-pdf-options';
10
+
11
+ const fallback: StoredPdfOptions = {
12
+ pageSize: 'a4',
13
+ orientation: 'portrait',
14
+ marginMm: 12,
15
+ };
16
+
17
+ export function loadPdfOptions(): StoredPdfOptions {
18
+ try {
19
+ const saved = localStorage.getItem(STORAGE_KEY);
20
+ if (!saved) return fallback;
21
+ const parsed = JSON.parse(saved) as Partial<StoredPdfOptions>;
22
+ return {
23
+ pageSize: parsed.pageSize === 'letter' ? 'letter' : 'a4',
24
+ orientation: parsed.orientation === 'landscape' ? 'landscape' : 'portrait',
25
+ marginMm: typeof parsed.marginMm === 'number' ? Math.min(32, Math.max(0, parsed.marginMm)) : fallback.marginMm,
26
+ };
27
+ } catch {
28
+ return fallback;
29
+ }
30
+ }
31
+
32
+ export function savePdfOptions(options: StoredPdfOptions): void {
33
+ try {
34
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(options));
35
+ } catch {}
36
+ }