Ctrl K
ComunidadGuías…

lib/page-format.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * page-format.mjs — one owner for paper size.
 *
 * The accepted values, the project default, the body width each one implies, the
 * CSS `@page size` keyword each one maps to, and the rule that ranks the ways a
 * format can be chosen. One owner, so the answer cannot depend on which script
 * the document came through.
 *
 * It used to. `build-cv-html.mjs` fell back to letter's `8.5in` body width while
 * `generate-pdf.mjs` fell back to `a4` in three places and
 * `generate-cover-letter.mjs` in a fourth. A payload declaring
 * `page_format: "letter"` was therefore laid out 8.5in wide and printed onto a
 * 210mm sheet, and modes/pdf.md had to tell the user to "Pass the SAME value" to
 * two scripts by hand. Page size is not in the filename and the PDF opens fine
 * either way, so a mismatched CV reaches a recruiter unnoticed.
 *
 * Resolution order, one direction:
 *
 *   explicit --format / payload page_format
 *     -> config/profile.yml `page_format`
 *       -> DEFAULT_PAGE_FORMAT
 *
 * A user states the size once in their profile instead of remembering a flag on
 * every render.
 *
 * `auto` is a third thing a user can state, not a third sheet. It defers the
 * choice to the employer's country (see `pageFormatForCountry`), and it is opt-in:
 * nobody who has not written it gets a different size.
 */
import { existsSync, readFileSync } from 'node:fs';
import { loadYaml } from './yaml-document.mjs';

/** Accepted `--format` / `page_format` values. */
export const PAGE_FORMATS = new Set(['a4', 'letter']);

/**
 * The size rendered when nobody has stated a preference.
 *
 * `letter`, because that is the contract the project already documents:
 * modes/pdf.md's payload table says `page_format` "Defaults to `letter`",
 * `build-cv-html.mjs` laid its body out that way, and the web's envelope parser
 * picked the same value for the same reason (#2185). The three `a4` fallbacks in
 * the PDF renderers were the outliers, and they are what produced the mismatch.
 */
export const DEFAULT_PAGE_FORMAT = 'letter';

/** The `config/profile.yml` key holding the user's standing preference. */
export const PROFILE_PAGE_FORMAT_KEY = 'page_format';

/**
 * Body width per format, for the HTML the CV template lays out.
 *
 * Lives here beside the `@page` keyword because the two must agree: the template
 * sizes the document body and the print rule sizes the sheet, and a mismatch
 * renders content laid out for one paper size onto another.
 */
export const PAGE_WIDTHS = { a4: '210mm', letter: '8.5in' };

/** CSS `@page { size: … }` keyword per format. */
export const PAGE_CSS_SIZE = { a4: 'A4', letter: 'Letter' };

/** The `page_format` value that defers the sheet to the employer's country. */
export const AUTO_PAGE_FORMAT = 'auto';

/**
 * Countries whose primary paper size is US Letter, as ISO 3166-1 alpha-2 codes:
 * Belize, Canada, Chile, Colombia, Costa Rica, Guatemala, Mexico, Nicaragua,
 * Panama, the Philippines, Puerto Rico, El Salvador, the United States, Venezuela.
 *
 * Source: Unicode CLDR, common/supplemental/supplementalData.xml, the
 * `<paperSize type="US-Letter">` element under `<measurementData>` (read at
 * unicode-org/cldr 86be3214). Its other element, `<paperSize type="A4"
 * territories="001">`, makes A4 the answer for every territory not listed here.
 * Edit this set only to follow a CLDR change.
 */
export const LETTER_COUNTRIES = new Set([
  'BZ', 'CA', 'CL', 'CO', 'CR', 'GT', 'MX', 'NI', 'PA', 'PH', 'PR', 'SV', 'US', 'VE',
]);

/**
 * Canonicalize one candidate value, or reject it.
 *
 * Case and surrounding whitespace are forgiven ("A4", " Letter ") because these
 * values arrive from a CLI flag, a YAML file and a JSON payload, three places a
 * person types them by hand. Anything else is null, so each caller decides
 * whether an unrecognized value is a hard error (the `--format` flag, which
 * fails loudly) or a fall-through to the next tier.
 *
 * @param {unknown} value
 * @returns {'a4'|'letter'|null}
 */
export function normalizePageFormat(value) {
  if (typeof value !== 'string') return null;
  const lower = value.trim().toLowerCase();
  return PAGE_FORMATS.has(lower) ? lower : null;
}

/**
 * Map an employer's country to a sheet.
 *
 * @param {unknown} country - An ISO 3166-1 alpha-2 code; case and padding forgiven.
 * @returns {'a4'|'letter'|null} null when the value is not a two-letter code, so
 *   the caller falls back to the project default rather than guessing a sheet.
 */
export function pageFormatForCountry(country) {
  if (typeof country !== 'string') return null;
  const code = country.trim().toUpperCase();
  if (!/^[A-Z]{2}$/.test(code)) return null;
  return LETTER_COUNTRIES.has(code) ? 'letter' : 'a4';
}

/** True when a candidate value is `auto`, in any casing and padding. */
function isAuto(value) {
  return typeof value === 'string' && value.trim().toLowerCase() === AUTO_PAGE_FORMAT;
}

/**
 * Read the standing preference out of a profile file.
 *
 * Best-effort, matching `readStyleTokens` in theme-style.mjs: config/profile.yml
 * is a USER-LAYER file the renderers consult for preferences, so a missing file,
 * malformed YAML or an unrecognized value all mean "nothing stated". A typo
 * there must never fail a render.
 *
 * `profilePath` carries no default on purpose. A relative one would resolve
 * against the caller's cwd, and the renderers anchor the profile to the tracker
 * workspace or the data root, so a cwd-relative fallback would quietly read a
 * different user's file. Callers pass the anchored path they already build.
 *
 * @param {string} [profilePath]
 * @returns {'a4'|'letter'|null}
 */
export function readProfilePageFormat(profilePath) {
  const setting = readProfileSetting(profilePath);
  return isAuto(setting) ? null : normalizePageFormat(setting);
}

/** The raw `page_format` value in the profile, or undefined when unreadable. */
function readProfileSetting(profilePath) {
  try {
    if (!profilePath || !existsSync(profilePath)) return undefined;
    const doc = loadYaml(readFileSync(profilePath, 'utf-8'));
    if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return undefined;
    return doc[PROFILE_PAGE_FORMAT_KEY];
  } catch {
    return undefined;
  }
}

/**
 * Rank an explicit choice, the user's profile and the project default.
 *
 * `auto` in either of the first two tiers hands the choice to `country`. An
 * explicit sheet still outranks a profile that says `auto`, and a country is
 * ignored unless `auto` is what won. A missing or malformed country under
 * `auto` takes the project default.
 *
 * @param {unknown} [explicit] - A `--format` flag or a payload's `page_format`.
 * @param {{profilePath?: string, country?: unknown}} [opts] - `country` is the
 *   employer's ISO 3166-1 alpha-2 code.
 * @returns {'a4'|'letter'}
 */
export function resolvePageFormat(explicit, { profilePath, country } = {}) {
  const sheet = normalizePageFormat(explicit);
  if (sheet) return sheet;
  const setting = isAuto(explicit) ? explicit : readProfileSetting(profilePath);
  if (isAuto(setting)) return pageFormatForCountry(country) ?? DEFAULT_PAGE_FORMAT;
  return normalizePageFormat(setting) ?? DEFAULT_PAGE_FORMAT;
}