react-pdf wraps text once at the first flex measure; when yoga then shrank the vendor column the cached lines ran under the INVOICE meta column. The header now pins column widths (299 + 24 + 192 pt), sizes the logo from its own aspect (stacked above the text when it would leave under 160 pt), uses absolute per-Text line heights, and turns hyphenation off. Also: repeating table header with an orphan-header guard, wrap=false rows, Page n of N footer, GSTIN printed only when present, one Text per address line, HTML twin header grid. The light-theme header showed the white-text wordmark; the ternary was inverted. Adds vitest + pdfjs-dist harness that fails on the old document (address overlaps Invoice No.) and passes on the new one.
63 lines
1.9 KiB
TypeScript
63 lines
1.9 KiB
TypeScript
export interface ImageSize {
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
function base64ToBytes(b64: string): Uint8Array {
|
|
const bin = atob(b64);
|
|
const out = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
return out;
|
|
}
|
|
|
|
const u16 = (b: Uint8Array, i: number) => (b[i] << 8) | b[i + 1];
|
|
const u32 = (b: Uint8Array, i: number) => ((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0;
|
|
|
|
function pngSize(b: Uint8Array): ImageSize | null {
|
|
const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
if (b.length < 24 || sig.some((v, i) => b[i] !== v)) return null;
|
|
return { width: u32(b, 16), height: u32(b, 20) };
|
|
}
|
|
|
|
function jpegSize(b: Uint8Array): ImageSize | null {
|
|
if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) return null;
|
|
let i = 2;
|
|
while (i + 3 < b.length) {
|
|
if (b[i] !== 0xff) {
|
|
i++;
|
|
continue;
|
|
}
|
|
const marker = b[i + 1];
|
|
if (marker === 0xff) {
|
|
i++; // fill byte
|
|
continue;
|
|
}
|
|
// Standalone markers carry no length.
|
|
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) {
|
|
i += 2;
|
|
continue;
|
|
}
|
|
const isSof =
|
|
marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
|
|
if (isSof) {
|
|
if (i + 8 >= b.length) return null;
|
|
return { height: u16(b, i + 5), width: u16(b, i + 7) };
|
|
}
|
|
i += 2 + u16(b, i + 2);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Pixel size of a PNG or JPEG data URI, or null when it is another format or malformed. */
|
|
export function imageSizeFromDataUri(uri: string): ImageSize | null {
|
|
const comma = uri.indexOf(",");
|
|
if (!uri.startsWith("data:") || comma < 0) return null;
|
|
try {
|
|
const bytes = base64ToBytes(uri.slice(comma + 1));
|
|
const size = pngSize(bytes) ?? jpegSize(bytes);
|
|
return size && size.width > 0 && size.height > 0 ? size : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|