` opening tag in the file.
// Quote style is intentionally permissive — single, double, or unquoted all
// match. Case-insensitive to handle `
` or `Data-Composition-Id` mid-edit.
const ROOT_COMPOSITION_DIV_RE =
/
]*?\bdata-composition-id\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)[^>]*>/i;
// `data-width` / `data-height` attribute extractors. Accept integer or float
// values, quoted or unquoted. The `\d` class restricts to ASCII digits — no
// locale comma surprises.
const DATA_WIDTH_RE =
/\bdata-width\s*=\s*(?:"(\d+(?:\.\d+)?)"|'(\d+(?:\.\d+)?)'|(\d+(?:\.\d+)?))(?=\s|>|\/)/i;
const DATA_HEIGHT_RE =
/\bdata-height\s*=\s*(?:"(\d+(?:\.\d+)?)"|'(\d+(?:\.\d+)?)'|(\d+(?:\.\d+)?))(?=\s|>|\/)/i;
function extractAttributeNumber(tag: string, re: RegExp): number | null {
const match = tag.match(re);
if (!match) return null;
// First capture group that matched (quoted-double | quoted-single | unquoted).
const raw = match[1] ?? match[2] ?? match[3];
if (raw === undefined) return null;
const value = Number(raw);
return Number.isFinite(value) ? value : null;
}
/**
* Parse the HTML at `entryHtmlPath` and detect which supported aspect ratio
* the composition's root div is authored at.
*
* Pure function except for `readFileSync` — no logging, no `process.exit`.
* The caller decides how to surface each result kind to the user.
*/
export function detectAspectRatioFromHtml(entryHtmlPath: string): AspectRatioDetection {
let html: string;
try {
html = readFileSync(entryHtmlPath, "utf-8");
} catch (err) {
return { kind: "read-error", error: normalizeErrorMessage(err) };
}
return detectAspectRatioFromHtmlString(html);
}
/**
* Same as `detectAspectRatioFromHtml`, but takes the HTML as a string instead
* of a file path. Exposed for tests + composition-string callers.
*/
export function detectAspectRatioFromHtmlString(html: string): AspectRatioDetection {
const tagMatch = html.match(ROOT_COMPOSITION_DIV_RE);
if (!tagMatch) return { kind: "no-root-div" };
const openTag = tagMatch[0];
const width = extractAttributeNumber(openTag, DATA_WIDTH_RE);
const height = extractAttributeNumber(openTag, DATA_HEIGHT_RE);
if (width === null || height === null) return { kind: "no-dims" };
if (width <= 0 || height <= 0) return { kind: "invalid-dims", width, height };
const ratio = width / height;
for (const candidate of SUPPORTED_RATIOS) {
if (Math.abs(ratio - candidate.ratio) <= RATIO_TOLERANCE) {
return { kind: "matched", aspectRatio: candidate.value, width, height };
}
}
return { kind: "no-match", width, height, ratio };
}
/**
* The tolerance used when matching the computed ratio to a supported value.
* Exposed for tests + caller introspection (e.g. warning messages that want
* to mention the bounds).
*/
export const ASPECT_RATIO_MATCH_TOLERANCE = RATIO_TOLERANCE;