convert.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  1. /*
  2. PDF Tool — conversion dialogs
  3. "Export Images" : renders the open document's pages to JPEG/PNG, including
  4. any overlays currently placed (what you see is what you get).
  5. "From Images" : builds a brand new PDF out of a list of images via pdf-lib,
  6. either onto page-sized "base plates" or pages matching each
  7. image. The result can be opened straight in the editor
  8. without ever touching the server.
  9. */
  10. (function (T) {
  11. 'use strict';
  12. var FONT = "'Helvetica Neue', Arial, sans-serif";
  13. /* Page sizes in PDF points (72 per inch) */
  14. var PAGE_PRESETS = {
  15. auto: null,
  16. a4p: [595.28, 841.89],
  17. a4l: [841.89, 595.28],
  18. a3p: [841.89, 1190.55],
  19. a3l: [1190.55, 841.89],
  20. letterp: [612, 792],
  21. letterl: [792, 612],
  22. legalp: [612, 1008]
  23. };
  24. /* Draw the editor's overlays onto an export canvas at its own resolution. */
  25. function drawOverlays(cx, overlays, W, H, imgMap) {
  26. (overlays || []).forEach(function (o) {
  27. if (o.type === 'text') {
  28. var fontPx = o.fontFrac * H;
  29. cx.fillStyle = o.color || '#000';
  30. cx.textBaseline = 'top';
  31. cx.font = fontPx + 'px ' + FONT;
  32. String(o.t).split('\n').forEach(function (ln, i) {
  33. cx.fillText(ln, o.fx * W, o.fy * H + i * fontPx * 1.25);
  34. });
  35. } else if (o.type === 'image' && imgMap[o.src]) {
  36. cx.drawImage(imgMap[o.src], o.fx * W, o.fy * H, o.fwFrac * W, o.fhFrac * H);
  37. }
  38. });
  39. }
  40. function preloadOverlayImages(overlaysByPage) {
  41. var srcs = {};
  42. Object.keys(overlaysByPage || {}).forEach(function (k) {
  43. (overlaysByPage[k] || []).forEach(function (o) {
  44. if (o.type === 'image') { srcs[o.src] = 1; }
  45. });
  46. });
  47. var map = {};
  48. return Promise.all(Object.keys(srcs).map(function (src) {
  49. return T.loadImage(src).then(function (img) { map[src] = img; },
  50. function () { /* skip an image that will not load */ });
  51. })).then(function () { return map; });
  52. }
  53. /* ── Export pages as images ─────────────────────────────────────── */
  54. T.openExportImages = function (doc) {
  55. if (!doc || !doc.pdf) { return; }
  56. if (!doc.savePath) {
  57. T.toast('Save this document first so the images have somewhere to go.', true);
  58. return;
  59. }
  60. var dlg = T.modal({
  61. title: 'Export Pages as Images',
  62. bodyHtml:
  63. '<div class="pt-field">' +
  64. '<span class="pt-label">Format</span>' +
  65. '<div class="pt-radios">' +
  66. '<label><input type="radio" name="exFmt" value="jpg" checked> JPEG</label>' +
  67. '<label><input type="radio" name="exFmt" value="png"> PNG</label>' +
  68. '</div>' +
  69. '</div>' +
  70. '<div class="pt-field" id="exQField">' +
  71. '<span class="pt-label">JPEG quality: <span id="exQVal">90</span>%</span>' +
  72. '<input type="range" id="exQ" min="50" max="100" value="90" style="width:100%">' +
  73. '</div>' +
  74. '<div class="pt-field">' +
  75. '<span class="pt-label">Resolution</span>' +
  76. '<select class="pt-select" id="exScale">' +
  77. '<option value="1">Standard (1x)</option>' +
  78. '<option value="2" selected>High (2x)</option>' +
  79. '<option value="3">Very high (3x)</option>' +
  80. '</select>' +
  81. '</div>' +
  82. '<p class="pt-hint">Saved next to the document. Text, images and chops you have placed are included.</p>' +
  83. '<div id="exProg"></div>',
  84. actions: [
  85. { label: 'Cancel', onClick: function (c) { c.close(); } },
  86. { label: 'Export', primary: true, onClick: run }
  87. ]
  88. });
  89. var qField = dlg.body.querySelector('#exQField');
  90. var qRange = dlg.body.querySelector('#exQ');
  91. qRange.addEventListener('input', function () {
  92. dlg.body.querySelector('#exQVal').textContent = this.value;
  93. });
  94. Array.prototype.forEach.call(dlg.body.querySelectorAll('input[name=exFmt]'), function (r) {
  95. r.addEventListener('change', function () {
  96. qField.style.display = this.value === 'jpg' ? '' : 'none';
  97. });
  98. });
  99. function run(ctx) {
  100. var fmt = dlg.body.querySelector('input[name=exFmt]:checked').value;
  101. var quality = parseInt(qRange.value, 10) / 100;
  102. var scale = parseFloat(dlg.body.querySelector('#exScale').value);
  103. var mime = fmt === 'png' ? 'image/png' : 'image/jpeg';
  104. var ext = fmt === 'png' ? '.png' : '.jpg';
  105. var outDir = T.dirOf(doc.savePath);
  106. var base = T.basenameNoExt(doc.savePath);
  107. var prog = dlg.body.querySelector('#exProg');
  108. ctx.button.disabled = true;
  109. prog.innerHTML = '<div class="pt-hint">Preparing…</div>';
  110. preloadOverlayImages(doc.overlays).then(function (imgMap) {
  111. var n = doc.pdf.numPages;
  112. function fail(err) {
  113. prog.innerHTML = '<div class="result-box result-err"><strong>Error:</strong> ' +
  114. T.escHtml(String(err && err.message ? err.message : err)) + '</div>';
  115. ctx.button.disabled = false;
  116. }
  117. function next(i) {
  118. if (i > n) {
  119. prog.innerHTML = '<div class="result-box result-ok"><strong>' + n +
  120. ' image' + (n !== 1 ? 's' : '') + '</strong> saved to ' + T.escHtml(outDir) + '</div>';
  121. ctx.button.disabled = false;
  122. return;
  123. }
  124. prog.innerHTML = '<div class="pt-hint">Rendering page ' + i + ' of ' + n + '…</div>';
  125. doc.pdf.getPage(i).then(function (page) {
  126. var vp = page.getViewport({ scale: scale });
  127. var c = document.createElement('canvas');
  128. c.width = vp.width;
  129. c.height = vp.height;
  130. var cx = c.getContext('2d');
  131. if (fmt !== 'png') {
  132. cx.fillStyle = '#FFF';
  133. cx.fillRect(0, 0, c.width, c.height);
  134. }
  135. page.render({ canvasContext: cx, viewport: vp }).promise.then(function () {
  136. drawOverlays(cx, doc.overlays[i], c.width, c.height, imgMap);
  137. c.toBlob(function (blob) {
  138. T.uploadBlob(blob, base + '_page' + i + ext, outDir)
  139. .then(function () { next(i + 1); }, fail);
  140. }, mime, quality);
  141. }, fail);
  142. }, fail);
  143. }
  144. next(1);
  145. });
  146. }
  147. };
  148. /* ── Build a PDF from images ────────────────────────────────────── */
  149. T.openImagesToPdf = function () {
  150. var images = [];
  151. var overrideDir = null;
  152. var dlg = T.modal({
  153. title: 'Create PDF from Images',
  154. bodyHtml:
  155. '<div class="pt-field">' +
  156. '<span class="pt-label">Images</span>' +
  157. '<div id="i2pList"></div>' +
  158. '<button class="pt-btn" id="i2pAdd" style="margin-top:8px">Add Images…</button>' +
  159. '</div>' +
  160. '<div class="pt-field">' +
  161. '<span class="pt-label">Page size</span>' +
  162. '<select class="pt-select" id="i2pSize">' +
  163. '<option value="auto" selected>Match each image</option>' +
  164. '<option value="a4p">A4 portrait</option>' +
  165. '<option value="a4l">A4 landscape</option>' +
  166. '<option value="a3p">A3 portrait</option>' +
  167. '<option value="a3l">A3 landscape</option>' +
  168. '<option value="letterp">Letter portrait</option>' +
  169. '<option value="letterl">Letter landscape</option>' +
  170. '<option value="legalp">Legal portrait</option>' +
  171. '</select>' +
  172. '<p class="pt-hint" style="margin:6px 0 0">Images are scaled to fit and centred on the page. ' +
  173. 'Nothing is cropped and the aspect ratio is kept.</p>' +
  174. '</div>' +
  175. '<div class="pt-field">' +
  176. '<span class="pt-label">Output filename</span>' +
  177. '<input type="text" class="pt-input" id="i2pName" placeholder="output.pdf">' +
  178. '</div>' +
  179. '<div class="pt-field">' +
  180. '<span class="pt-label">Save to folder</span>' +
  181. '<div class="pt-row">' +
  182. '<button class="pt-btn" id="i2pDir">Choose…</button>' +
  183. '<span class="pt-path" id="i2pDirLbl">Same folder as the first image</span>' +
  184. '</div>' +
  185. '</div>' +
  186. '<div id="i2pProg"></div>',
  187. actions: [
  188. { label: 'Cancel', onClick: function (c) { c.close(); } },
  189. { label: 'Open in Editor', onClick: openInEditor },
  190. { label: 'Save to Server', primary: true, onClick: saveToServer }
  191. ]
  192. });
  193. var listEl = dlg.body.querySelector('#i2pList');
  194. var progEl = dlg.body.querySelector('#i2pProg');
  195. function renderList() {
  196. if (!images.length) {
  197. listEl.innerHTML = '<div class="pt-empty-box">No images added yet.</div>';
  198. return;
  199. }
  200. var html = '<div class="pt-imglist">';
  201. images.forEach(function (img, idx) {
  202. html += '<div class="pt-imgrow">' +
  203. '<img class="pt-thumb" src="' + T.mediaUrl(img.filepath) + '" alt="">' +
  204. '<span class="pt-imgname">' + T.escHtml(img.filename) + '</span>' +
  205. '<span class="pt-imgbtns">' +
  206. (idx > 0 ? '<button class="pt-iconbtn" data-mv="-1" data-i="' + idx + '" title="Move up">' + T.svg.up + '</button>' : '') +
  207. (idx < images.length - 1 ? '<button class="pt-iconbtn" data-mv="1" data-i="' + idx + '" title="Move down">' + T.svg.down + '</button>' : '') +
  208. '<button class="pt-iconbtn danger" data-rm="' + idx + '" title="Remove">' + T.svg.x + '</button>' +
  209. '</span>' +
  210. '</div>';
  211. });
  212. listEl.innerHTML = html + '</div>';
  213. Array.prototype.forEach.call(listEl.querySelectorAll('[data-mv]'), function (b) {
  214. b.addEventListener('click', function () {
  215. var i = parseInt(this.getAttribute('data-i'), 10);
  216. var j = i + parseInt(this.getAttribute('data-mv'), 10);
  217. if (j < 0 || j >= images.length) { return; }
  218. var tmp = images[i]; images[i] = images[j]; images[j] = tmp;
  219. renderList();
  220. });
  221. });
  222. Array.prototype.forEach.call(listEl.querySelectorAll('[data-rm]'), function (b) {
  223. b.addEventListener('click', function () {
  224. images.splice(parseInt(this.getAttribute('data-rm'), 10), 1);
  225. renderList();
  226. });
  227. });
  228. }
  229. dlg.body.querySelector('#i2pAdd').addEventListener('click', function () {
  230. ao_module_openFileSelector('_ptI2pAddCb', 'user:/', 'file', true,
  231. { filter: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'] });
  232. });
  233. dlg.body.querySelector('#i2pDir').addEventListener('click', function () {
  234. ao_module_openFileSelector('_ptI2pDirCb', 'user:/', 'folder', false);
  235. });
  236. window._ptI2pAddCb = function (files) {
  237. if (!files || !files.length) { return; }
  238. files.forEach(function (f) {
  239. if (!images.some(function (x) { return x.filepath === f.filepath; })) {
  240. images.push({ filepath: f.filepath, filename: f.filename });
  241. }
  242. });
  243. renderList();
  244. };
  245. window._ptI2pDirCb = function (files) {
  246. if (!files || !files.length) { return; }
  247. overrideDir = files[0].filepath;
  248. dlg.body.querySelector('#i2pDirLbl').textContent = overrideDir;
  249. };
  250. function outputName() {
  251. var name = dlg.body.querySelector('#i2pName').value.trim() || 'output';
  252. return /\.pdf$/i.test(name) ? name : name + '.pdf';
  253. }
  254. /* Build the document in memory and hand back its bytes. */
  255. function build(ctx) {
  256. if (!images.length) {
  257. progEl.innerHTML = '<div class="result-box result-err">Add at least one image first.</div>';
  258. return Promise.reject(null);
  259. }
  260. var preset = PAGE_PRESETS[dlg.body.querySelector('#i2pSize').value] || null;
  261. ctx.button.disabled = true;
  262. progEl.innerHTML = '<div class="pt-hint">Loading pdf-lib…</div>';
  263. return T.loadPdfLib().then(function () {
  264. return PDFLib.PDFDocument.create();
  265. }).then(function (pdfDoc) {
  266. return images.reduce(function (chain, im, i) {
  267. return chain.then(function () {
  268. progEl.innerHTML = '<div class="pt-hint">Adding image ' + (i + 1) + ' of ' + images.length + '…</div>';
  269. return T.embedImage(pdfDoc, T.mediaUrl(im.filepath)).then(function (emb) {
  270. if (!preset) {
  271. // One page per image, exactly the image's own size
  272. var p = pdfDoc.addPage([emb.width, emb.height]);
  273. p.drawImage(emb, { x: 0, y: 0, width: emb.width, height: emb.height });
  274. return;
  275. }
  276. // Fixed base plate: scale to fit inside, keep aspect, centre it
  277. var pw = preset[0], ph = preset[1];
  278. var s = Math.min(pw / emb.width, ph / emb.height);
  279. var w = emb.width * s, h = emb.height * s;
  280. var page = pdfDoc.addPage([pw, ph]);
  281. page.drawImage(emb, { x: (pw - w) / 2, y: (ph - h) / 2, width: w, height: h });
  282. });
  283. });
  284. }, Promise.resolve()).then(function () {
  285. progEl.innerHTML = '<div class="pt-hint">Writing PDF…</div>';
  286. return pdfDoc.save();
  287. });
  288. }).catch(function (err) {
  289. if (err) {
  290. progEl.innerHTML = '<div class="result-box result-err"><strong>Error:</strong> ' +
  291. T.escHtml(String(err.message || err)) + '</div>';
  292. }
  293. ctx.button.disabled = false;
  294. throw err;
  295. });
  296. }
  297. /* Straight into the editor — never round-trips through the server */
  298. function openInEditor(ctx) {
  299. build(ctx).then(function (bytes) {
  300. ctx.button.disabled = false;
  301. ctx.close();
  302. T.openBytes(bytes, outputName(), null);
  303. }).catch(function () { /* message already shown */ });
  304. }
  305. function saveToServer(ctx) {
  306. var name = outputName();
  307. build(ctx).then(function (bytes) {
  308. var outDir = overrideDir || T.dirOf(images[0].filepath);
  309. progEl.innerHTML = '<div class="pt-hint">Uploading…</div>';
  310. return T.uploadBlob(new Blob([bytes], { type: 'application/pdf' }), name, outDir)
  311. .then(function () {
  312. progEl.innerHTML = '<div class="result-box result-ok"><strong>' + T.escHtml(name) +
  313. '</strong> created in ' + T.escHtml(outDir) + '</div>';
  314. ctx.button.disabled = false;
  315. });
  316. }).catch(function (err) {
  317. if (err) {
  318. progEl.innerHTML = '<div class="result-box result-err"><strong>Error:</strong> ' +
  319. T.escHtml(String(err.message || err)) + '</div>';
  320. }
  321. ctx.button.disabled = false;
  322. });
  323. }
  324. renderList();
  325. };
  326. })(window.PDFTool);