Files
ackandClaude Fable 5.1 95c80d04c9 Kokkoja: first public assembly
Assembled from the development repository by tools/make_release.sh.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 15:58:02 +02:00

89 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);
}
}