88 lines
3.1 KiB
JavaScript
88 lines
3.1 KiB
JavaScript
// @ts-check
|
|||
|
|
/**
|
||
|
|
* A picture of a page: the page file drawn into a PNG, for use outside
|
||
|
|
* Kokkoja.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { pageToSvg } from './page_file.js';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The resolution of an exported picture, in dots per inch: enough to print
|
||
|
|
* handwriting cleanly, and an A4 page is then 1654 by 2339 pixels.
|
||
|
|
*/
|
||
|
|
export const EXPORT_DPI = 200;
|
||
|
|
|
||
|
|
/** A very large page is exported at a lower resolution, to stay within this. */
|
||
|
|
const MOST_PIXELS_ON_A_SIDE = 10000;
|
||
|
|
|
||
|
|
const CRC_TABLE = new Uint32Array(256).map((_, n) => {
|
||
|
|
let c = n;
|
||
|
|
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||
|
|
return c;
|
||
|
|
});
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {Uint8Array} bytes
|
||
|
|
* @returns {number} The checksum that ends a PNG chunk.
|
||
|
|
*/
|
||
|
|
function crc32(bytes) {
|
||
|
|
let c = 0xffffffff;
|
||
|
|
for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8);
|
||
|
|
return (c ^ 0xffffffff) >>> 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Record a PNG's resolution in it (a pHYs chunk, placed after the header
|
||
|
|
* chunk), so that other programs print the page at its true size, and so
|
||
|
|
* that Kokkoja places it at that size if it is loaded as an image.
|
||
|
|
* @param {Uint8Array<ArrayBuffer>} png
|
||
|
|
* @param {number} dpi
|
||
|
|
* @returns {Uint8Array<ArrayBuffer>}
|
||
|
|
*/
|
||
|
|
function withResolution(png, dpi) {
|
||
|
|
const HEADER_END = 8 + 25; // The signature, then the IHDR chunk.
|
||
|
|
const chunk = new Uint8Array(21);
|
||
|
|
const view = new DataView(chunk.buffer);
|
||
|
|
const perMetre = Math.round(dpi / 0.0254);
|
||
|
|
view.setUint32(0, 9);
|
||
|
|
chunk.set([0x70, 0x48, 0x59, 0x73], 4); // "pHYs"
|
||
|
|
view.setUint32(8, perMetre);
|
||
|
|
view.setUint32(12, perMetre);
|
||
|
|
chunk[16] = 1; // The unit is the metre.
|
||
|
|
view.setUint32(17, crc32(chunk.subarray(4, 17)));
|
||
|
|
const out = new Uint8Array(png.length + chunk.length);
|
||
|
|
out.set(png.subarray(0, HEADER_END));
|
||
|
|
out.set(chunk, HEADER_END);
|
||
|
|
out.set(png.subarray(HEADER_END), HEADER_END + chunk.length);
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {import('./page_model.js').Page} page
|
||
|
|
* @param {Parameters<typeof pageToSvg>[1]} options As for the page file;
|
||
|
|
* layers named in `hidden` are left out.
|
||
|
|
* @returns {Promise<Blob>} A PNG picture of the page.
|
||
|
|
*/
|
||
|
|
export async function pageToPng(page, options) {
|
||
|
|
const scale = Math.min(EXPORT_DPI / 254, MOST_PIXELS_ON_A_SIDE / Math.max(page.width, page.height));
|
||
|
|
const address = URL.createObjectURL(
|
||
|
|
new Blob([pageToSvg(page, options)], { type: 'image/svg+xml' }));
|
||
|
|
try {
|
||
|
|
const drawing = new Image();
|
||
|
|
drawing.src = address;
|
||
|
|
await drawing.decode();
|
||
|
|
const canvas = document.createElement('canvas');
|
||
|
|
canvas.width = Math.round(page.width * scale);
|
||
|
|
canvas.height = Math.round(page.height * scale);
|
||
|
|
const context = /** @type {CanvasRenderingContext2D} */ (canvas.getContext('2d'));
|
||
|
|
context.drawImage(drawing, 0, 0, canvas.width, canvas.height);
|
||
|
|
/** @type {Blob} */
|
||
|
|
const blob = await new Promise((resolve, reject) => canvas.toBlob(
|
||
|
|
(result) => (result ? resolve(result) : reject(new Error('The picture could not be made.'))),
|
||
|
|
'image/png'));
|
||
|
|
const png = withResolution(new Uint8Array(await blob.arrayBuffer()), scale * 254);
|
||
|
|
return new Blob([png], { type: 'image/png' });
|
||
|
|
} finally {
|
||
|
|
URL.revokeObjectURL(address);
|
||
|
|
}
|
||
|
|
}
|