5180 fichiers 38.5 Mo 0 téléchargements 218 vues mis à jour il y a 5 heures ui
nitro-v36-with-renderer-v36 / src / common / native-text / CanvasSpacedText.ts
CanvasSpacedText.ts 22 Ko Télécharger le fichier
// Text with an explicit letter spacing, drawn like the official classic-js v75 client does it. Its AIR32 glyph renderer refuses a non-zero
// letterSpacing (Wc.supports), so the client falls back to the browser canvas: the whole string is one fillText whose maxWidth is the laid-out
// width (glyphs are squeezed, never spaced), drawn on a 2x canvas with a per-font vertical scale found by a calibration search, then downscaled
// with smoothing "high". Measuring, field size, baseline and the calibration below follow the client's TextField (HabboAirLauncher.app.js:
// _r121392d1d01cef, _r64aca03d869337, _r7b5b881a01c0c8, _rdfb691d66bce08, _ref22c5ac98e5b8) and its vertical calibrator (class uZ).
// The pixels come from the browser's own text rasteriser, so they match the client only in the same browser and platform. Real AIR output is not proven.
//
// Options the client's canvas fallback ignores on purpose, so they are ignored here too (changing the raster for them would diverge from the client):
// sharpness, thickness, kerning (the canvas kerns with fontKerning "auto"; the layout's kerning=false only parametrises its AIR32 renderer), antiAliasType and
// gridFitType. Options it does not draw this way (underline, etching, leading, italic, another colour or surface) are declined by supportsCanvasSpacedText.

const FONT_URLS = {
    regular: new URL('../../assets/webfonts/Ubuntu.ttf', import.meta.url).href,
    bold: new URL('../../assets/webfonts/Ubuntu-b.ttf', import.meta.url).href,
    italic: new URL('../../assets/webfonts/Ubuntu-i.ttf', import.meta.url).href,
    boldItalic: new URL('../../assets/webfonts/Ubuntu-ib.ttf', import.meta.url).href
};

const GUTTER = 2;
const RIGHT_MARGIN = 1;
const BOTTOM_MARGIN = 1;
const TOP_MARGIN = 2;
const SUPERSAMPLE = 2;
const HIGH_RESOLUTION = 6;

export interface CanvasSpacedTextStyle {
    family: string;
    size: number;
    bold?: boolean;
    italic?: boolean;
    color?: number;
    letterSpacing: number;
}

export interface CanvasSpacedTextRaster {
    canvas: HTMLCanvasElement | OffscreenCanvas;
    /** Field size in layout pixels; the canvas is `scale` times larger. */
    width: number;
    height: number;
}

interface VerticalMetrics {
    ascent: number;
    descent: number;
    lineHeight: number;
}

type Canvas = HTMLCanvasElement | OffscreenCanvas;
type Context = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;

/** The native text renderer's own field limit (NativeText skips a field above 2 MP); every canvas here, large or probe, is held to it before it is created. */
const MAX_CANVAS_PIXELS = 2 * 1024 * 1024;
const MAX_CANVAS_SIDE = 16384;
const MAX_FONT_SIZE = 256;
const MAX_LETTER_SPACING = 64;
const MAX_SCALE = 4;

const createCanvas = (width: number, height: number): Canvas | null => {
    if (!Number.isFinite(width) || !Number.isFinite(height)) return null;

    const w = Math.max(1, Math.ceil(width));
    const h = Math.max(1, Math.ceil(height));

    if (w > MAX_CANVAS_SIDE || h > MAX_CANVAS_SIDE || w * h > MAX_CANVAS_PIXELS) return null;

    if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(w, h);

    if (typeof document !== 'undefined') {
        const canvas = document.createElement('canvas');

        canvas.width = w;
        canvas.height = h;

        return canvas;
    }

    return null;
};

const contextOf = (canvas: Canvas | null): Context | null => (canvas?.getContext('2d', { willReadFrequently: true }) as Context) ?? null;

const fontString = (style: Pick<CanvasSpacedTextStyle, 'size' | 'bold' | 'italic' | 'family'>) =>
    `${style.italic ? 'italic ' : ''}${style.bold ? 'bold ' : ''}${style.size}px ${style.family}`;

const fontUrl = (style: Pick<CanvasSpacedTextStyle, 'bold' | 'italic'>) =>
    style.bold ? (style.italic ? FONT_URLS.boldItalic : FONT_URLS.bold) : style.italic ? FONT_URLS.italic : FONT_URLS.regular;

let measureContext: Context | null | undefined;

const getMeasureContext = () => {
    if (measureContext === undefined) measureContext = contextOf(createCanvas(1, 1));

    return measureContext;
};

export interface CanvasSpacedTextSurface {
    /** The surface colour NativeText was asked to composite against. */
    background: number;
    maxWidth?: number;
    leading?: number;
    underline?: boolean;
    etched?: boolean;
}

/**
 * The only combination proven against the client: white text on a black surface (the promo's screen-blended composition), one line, no
 * underline/etching/leading/italic, a finite integer size and a finite spacing. Anything else keeps the existing native renderer.
 */
export const supportsCanvasSpacedText = (style: CanvasSpacedTextStyle, surface: CanvasSpacedTextSurface): boolean =>
    style.family === 'Ubuntu' &&
    Number.isInteger(style.size) &&
    style.size >= 1 &&
    style.size <= MAX_FONT_SIZE &&
    Number.isFinite(style.letterSpacing) &&
    style.letterSpacing !== 0 &&
    Math.abs(style.letterSpacing) <= MAX_LETTER_SPACING &&
    !style.italic &&
    (style.color ?? 0) === 0xffffff &&
    surface.background === 0x000000 &&
    surface.maxWidth === undefined &&
    !surface.leading &&
    !surface.underline &&
    !surface.etched;

/** The client measures a line with the 2D context (kerning auto) and adds the spacing between UTF-16 units: ceil(width + letterSpacing * (length - 1)). */
export const measureCanvasSpacedText = (text: string, style: CanvasSpacedTextStyle): number | null => {
    if (text.length === 0) return 0;

    const context = getMeasureContext();

    if (!context) return null;

    context.font = fontString(style);

    const spacing = style.letterSpacing !== 0 && text.length > 1 ? style.letterSpacing * (text.length - 1) : 0;
    const width = Math.max(0, Math.ceil(context.measureText(text).width + spacing));

    return Number.isFinite(width) ? width : null;
};

// Ascender / descender / line gap of the font file: OS/2 typo values when USE_TYPO_METRICS is set, else hhea with a line gap of 0 (the client's H0r).
const readVerticalMetrics = (buffer: ArrayBuffer) => {
    const view = new DataView(buffer);
    const count = view.getUint16(4);
    const tables = new Map<string, number>();

    for (let index = 0; index < count; index++) {
        const offset = 12 + index * 16;
        let tag = '';

        for (let character = 0; character < 4; character++) tag += String.fromCharCode(view.getUint8(offset + character));

        tables.set(tag, view.getUint32(offset + 8));
    }

    const head = tables.get('head');
    const hhea = tables.get('hhea');
    const os2 = tables.get('OS/2');

    if (head === undefined || hhea === undefined) return null;

    const unitsPerEm = Math.max(1, view.getUint16(head + 18));
    const hheaAscender = view.getInt16(hhea + 4);
    const hheaDescender = view.getInt16(hhea + 6);

    if (os2 !== undefined && (view.getUint16(os2 + 62) & 128) !== 0) {
        return { unitsPerEm, ascender: view.getInt16(os2 + 68), descender: view.getInt16(os2 + 70), lineGap: view.getInt16(os2 + 72) };
    }

    return { unitsPerEm, ascender: hheaAscender, descender: hheaDescender, lineGap: 0 };
};

const metricsCache = new Map<string, Promise<ReturnType<typeof readVerticalMetrics>>>();

const loadVerticalMetrics = (style: Pick<CanvasSpacedTextStyle, 'bold' | 'italic'>) => {
    const url = fontUrl(style);
    let cached = metricsCache.get(url);

    if (!cached) {
        cached = fetch(url).then(async (response) => {
            if (!response.ok) throw new Error(`Ubuntu font ${url}: HTTP ${response.status}`);

            return readVerticalMetrics(await response.arrayBuffer());
        });
        metricsCache.set(url, cached);
        cached.catch(() => metricsCache.delete(url));
    }

    return cached;
};

const scaleMetrics = (raw: NonNullable<ReturnType<typeof readVerticalMetrics>>, size: number): VerticalMetrics => {
    const factor = size / Math.max(1, raw.unitsPerEm);
    const ascent = raw.ascender * factor;
    const descent = Math.abs(raw.descender) * factor;

    return { ascent, descent, lineHeight: ascent + descent + raw.lineGap * factor };
};

// ---- the client's vertical calibration (class uZ): the glyph "o" is drawn on a 6x canvas, reduced to 2x and to 1x at a vertical scale, and the
// scale whose top and bottom ink rows are most solid wins; the search narrows a 12 point grid four times.

interface CalibrationSpec {
    key: string;
    font: string;
    size: number;
    resolution: number;
    highResolution: number;
}

interface InkProbe {
    top: number;
    bottom: number;
    centre: number;
    topScore: number;
    bottomScore: number;
}

const CALIBRATION_GLYPH = 'o';
const CALIBRATION_EXPLORATION = 1.2;
const CALIBRATION_SAMPLES = 12;
const CALIBRATION_ITERATIONS = 4;
const INK_ROW_THRESHOLD = 8;
const INK_COLUMN_THRESHOLD = 4;

const luminance = (data: Uint8ClampedArray, width: number, x: number, y: number) => {
    const index = (y * width + x) * 4;

    return 0.2126 * (data[index] ?? 0) + 0.7152 * (data[index + 1] ?? 0) + 0.0722 * (data[index + 2] ?? 0);
};

const strongestRun = (image: ImageData, row: number, threshold: number) => {
    let best: { start: number; end: number; strength: number } | null = null;
    let start = -1;
    let strength = 0;

    for (let x = 0; x < image.width; x++) {
        const value = luminance(image.data, image.width, x, row);

        if (value > threshold) {
            if (start < 0) {
                start = x;
                strength = 0;
            }

            strength += value;
            continue;
        }

        if (start >= 0) {
            const run = { start, end: x - 1, strength };

            if (best === null || run.strength > best.strength) best = run;

            start = -1;
            strength = 0;
        }
    }

    if (start >= 0) {
        const run = { start, end: image.width - 1, strength };

        if (best === null || run.strength > best.strength) best = run;
    }

    return best;
};

const scanColumn = (image: ImageData, x: number, from: number, step: number, threshold: number) => {
    for (let y = from; y >= 0 && y < image.height; y += step) if (luminance(image.data, image.width, x, y) > threshold) return y;

    return -1;
};

const probeInk = (image: ImageData): InkProbe | null => {
    let bottomRow = -1;

    for (let y = image.height - 1; y >= 0; y--) {
        if (strongestRun(image, y, INK_ROW_THRESHOLD) !== null) {
            bottomRow = y;
            break;
        }
    }

    if (bottomRow < 0) return null;

    const run = strongestRun(image, bottomRow, INK_ROW_THRESHOLD);

    if (run === null) return null;

    const column = Math.trunc((run.start + run.end) / 2);
    const bottom = scanColumn(image, column, image.height - 1, -1, INK_ROW_THRESHOLD);
    const top = scanColumn(image, column, 0, 1, INK_COLUMN_THRESHOLD);

    if (bottom < 0 || top < 0 || top >= bottom) return null;

    const half = Math.floor((bottom - top + 1) / 2);

    if (half <= 0) return null;

    const upperEnd = top + half - 1;
    const lowerStart = bottom - half + 1;
    let upperSum = 0;
    let lowerSum = 0;

    for (let offset = 0; offset < half; offset++) {
        const upper = luminance(image.data, image.width, column, top + offset);
        const lower = luminance(image.data, image.width, column, bottom - offset);

        upperSum += upper * upper;
        lowerSum += lower * lower;
    }

    const topScore = upperSum / half;
    const bottomScore = lowerSum / half;

    return { top, bottom, centre: (upperEnd + lowerStart) / 2, topScore, bottomScore };
};

const measureGlyph = (font: string) => {
    const context = getMeasureContext();

    if (!context) return { width: 12, ascent: 9, descent: 3 };

    context.font = font;

    const metrics = context.measureText(CALIBRATION_GLYPH);

    return {
        width: Math.max(1, metrics.width),
        ascent: Math.max(1, metrics.actualBoundingBoxAscent ?? metrics.fontBoundingBoxAscent ?? 9),
        descent: Math.max(1, metrics.actualBoundingBoxDescent ?? metrics.fontBoundingBoxDescent ?? 3)
    };
};

const marginX = (size: number) => Math.max(8, Math.ceil(size));
const marginY = (size: number) => Math.max(16, Math.ceil(size * 2));

const drawProbe = (spec: CalibrationSpec, scale: number, baselineOffset: number | null, storedOffset: number): ImageData | null => {
    const glyph = measureGlyph(spec.font);
    const sideMargin = marginX(spec.size);
    const verticalMargin = marginY(spec.size);
    const width = Math.max(16, Math.ceil(glyph.width) + sideMargin * 2);
    const height = Math.max(16, Math.ceil(glyph.ascent + glyph.descent) + verticalMargin * 2);
    const base = Math.max(1, spec.resolution);
    const high = Math.max(base, spec.highResolution);
    const highCanvas = createCanvas(width * high, height * high);
    const mediumCanvas = createCanvas(width * base, height * base);
    const finalCanvas = createCanvas(width, height);
    const highContext = contextOf(highCanvas);
    const mediumContext = contextOf(mediumCanvas);
    const finalContext = contextOf(finalCanvas);

    if (!highCanvas || !mediumCanvas || !finalCanvas || !highContext || !mediumContext || !finalContext) return null;

    const x = Math.max(sideMargin, Math.round((width - glyph.width) / 2));
    const baseline = verticalMargin + glyph.ascent;
    const offset = Math.max(0, baselineOffset ?? storedOffset);
    const lift = Math.max(0, baseline - offset);
    const drawHeight = mediumCanvas.height * scale;
    const drawTop = lift - lift * base * scale;

    highContext.setTransform(1, 0, 0, 1, 0, 0);
    highContext.clearRect(0, 0, highCanvas.width, highCanvas.height);
    highContext.fillStyle = '#000000';
    highContext.fillRect(0, 0, highCanvas.width, highCanvas.height);
    highContext.setTransform(high, 0, 0, high, 0, 0);
    highContext.textBaseline = 'alphabetic';
    highContext.font = spec.font;
    highContext.fillStyle = '#ffffff';
    highContext.fillText(CALIBRATION_GLYPH, x, baseline);

    mediumContext.setTransform(1, 0, 0, 1, 0, 0);
    mediumContext.clearRect(0, 0, mediumCanvas.width, mediumCanvas.height);
    mediumContext.fillStyle = '#000000';
    mediumContext.fillRect(0, 0, mediumCanvas.width, mediumCanvas.height);
    mediumContext.imageSmoothingEnabled = true;
    mediumContext.imageSmoothingQuality = 'high';
    mediumContext.drawImage(highCanvas as CanvasImageSource, 0, 0, mediumCanvas.width, mediumCanvas.height);

    finalContext.setTransform(1, 0, 0, 1, 0, 0);
    finalContext.clearRect(0, 0, width, height);
    finalContext.fillStyle = '#000000';
    finalContext.fillRect(0, 0, width, height);
    finalContext.imageSmoothingEnabled = true;
    finalContext.imageSmoothingQuality = 'high';
    finalContext.drawImage(mediumCanvas as CanvasImageSource, 0, drawTop, width, drawHeight);

    return finalContext.getImageData(0, 0, width, height);
};

const roundScale = (value: number) => Math.round(value * 1e12) / 1e12;

const gridBetween = (a: number, b: number, count: number) => {
    const low = Math.min(a, b);
    const high = Math.max(a, b);

    if (count <= 1 || Math.abs(high - low) <= Number.EPSILON) return [low];

    const step = (high - low) / (count - 1);

    return Array.from({ length: count }, (_, index) => low + step * index);
};

const neighbours = (values: number[], index: number) => {
    if (values.length <= 1 || index < 0 || index >= values.length) return null;

    let start = Math.max(0, index - 1);
    let end = Math.min(values.length - 1, index + 1);

    if (start === end) {
        if (end < values.length - 1) end++;
        else if (start > 0) start--;
    }

    return { start: values[start], end: values[end] };
};

const searchScale = (base: number, limit: number, evaluate: (scale: number) => InkProbe | null, pick: (probe: InkProbe) => number) => {
    let winner: number | null = null;
    let best = Number.NEGATIVE_INFINITY;
    let low = base;
    let high = limit;

    for (let iteration = 0; iteration < CALIBRATION_ITERATIONS; iteration++) {
        const grid = gridBetween(low, high, CALIBRATION_SAMPLES);
        let roundBest: number | null = null;
        let roundScore = Number.NEGATIVE_INFINITY;
        let roundIndex = -1;

        for (let index = 0; index < grid.length; index++) {
            const candidate = grid[index];
            const probe = evaluate(candidate);

            if (probe === null) continue;

            const score = pick(probe);

            if (!Number.isFinite(score)) continue;

            if (score > best) {
                best = score;
                winner = candidate;
            }

            if (score > roundScore) {
                roundScore = score;
                roundBest = candidate;
                roundIndex = index;
            }
        }

        if (roundBest === null || roundIndex < 0 || grid.length <= 1) break;

        const next = neighbours(grid, roundIndex);

        if (next === null) break;

        low = next.start;
        high = next.end;
    }

    return winner;
};

const baselineOffsetFor = (spec: CalibrationSpec, probe: InkProbe | null) => {
    if (probe === null) return 0;

    const glyph = measureGlyph(spec.font);

    return Math.max(0, marginY(spec.size) + glyph.ascent - probe.centre);
};

interface Calibration {
    scaleY: number;
    baselineOffset: number;
}

const calibrations = new Map<string, Calibration>();

/** The client's per-font vertical scale (1 / resolution when nothing better is found) and the baseline offset the draw subtracts. */
const calibrate = (spec: CalibrationSpec): Calibration | null => {
    const cached = calibrations.get(spec.key);

    if (cached) return cached;

    const base = 1 / Math.max(1, spec.resolution);
    const probeAt = (scale: number, offset: number | null) => {
        const image = drawProbe(spec, scale, offset, 0);

        return image === null ? null : probeInk(image);
    };
    const initial = probeAt(base, 0);

    // Without a probe (an allocation above the limit, an unreadable canvas) there is nothing to calibrate against: decline instead of drawing uncalibrated.
    if (initial === null) return null;

    const baselineOffset = baselineOffsetFor(spec, initial);
    const memo = new Map<number, InkProbe | null>();
    const evaluate = (scale: number) => {
        const key = roundScale(scale);

        if (memo.has(key)) return memo.get(key) ?? null;

        const probe = key === roundScale(base) ? initial : probeAt(scale, baselineOffset);

        memo.set(key, probe);

        return probe;
    };
    const start = Math.max(Number.EPSILON, base);
    const first = evaluate(start);
    const inkHeight = first === null ? 0 : Math.max(0, first.bottom - first.top + 1);
    const limit = inkHeight <= 0 ? start * CALIBRATION_EXPLORATION : start * ((inkHeight + 1) / inkHeight);
    const top = searchScale(start, limit, evaluate, (probe) => probe.topScore);
    const bottom = searchScale(start, limit, evaluate, (probe) => probe.bottomScore);
    const scaleY = top === null && bottom === null ? start : top === null ? (bottom ?? start) : bottom === null ? top : (top + bottom) / 2;
    const result = { scaleY, baselineOffset };

    calibrations.set(spec.key, result);

    return result;
};

/** The supersampled canvas and the downscale to layout pixels (times `scale`); null when the browser cannot draw it. */
export const renderCanvasSpacedText = async (text: string, style: CanvasSpacedTextStyle, scale = 1): Promise<CanvasSpacedTextRaster | null> => {
    if (!supportsCanvasSpacedText(style, { background: 0x000000 })) return null;

    if (text.length === 0 || /[\r\n]/.test(text) || typeof document === 'undefined' || !document.fonts) return null;

    if (!Number.isInteger(scale) || scale < 1 || scale > MAX_SCALE) return null;

    const font = fontString(style);

    await document.fonts.load(font, text);

    if (!document.fonts.check(font, text)) return null;

    const raw = await loadVerticalMetrics(style);

    if (!raw) return null;

    const textWidth = measureCanvasSpacedText(text, style);

    if (textWidth === null) return null;

    const vertical = scaleMetrics(raw, style.size);
    const width = Math.ceil(textWidth + GUTTER + RIGHT_MARGIN);
    const height = Math.ceil(vertical.lineHeight + TOP_MARGIN + BOTTOM_MARGIN);
    const resolution = SUPERSAMPLE * Math.max(1, scale);
    const spec: CalibrationSpec = { key: `${font}|${resolution}`, font, size: style.size, resolution, highResolution: Math.max(resolution, HIGH_RESOLUTION) };
    const calibration = calibrate(spec);

    if (calibration === null) return null;

    const { scaleY, baselineOffset } = calibration;
    const largeCanvas = createCanvas(width * resolution, height * resolution);
    const largeContext = contextOf(largeCanvas);
    const outputCanvas = createCanvas(width * scale, height * scale);
    const outputContext = contextOf(outputCanvas);

    if (!largeCanvas || !largeContext || !outputCanvas || !outputContext) return null;

    // The line's baseline: the gutter-less line top (2) plus the font's ascent, rounded, then lifted by the calibration offset.
    const baseline = Math.round(TOP_MARGIN + vertical.ascent);
    const lift = Math.max(0, baseline - baselineOffset);
    const maxWidth = Math.max(1, width - (GUTTER + RIGHT_MARGIN));
    const colour = ((style.color ?? 0) >>> 0) & 0xffffff;

    largeContext.setTransform(1, 0, 0, 1, 0, 0);
    largeContext.clearRect(0, 0, largeCanvas.width, largeCanvas.height);
    largeContext.imageSmoothingEnabled = false;
    largeContext.setTransform(resolution, 0, 0, resolution, 0, 0);
    largeContext.textBaseline = 'alphabetic';
    largeContext.font = font;
    largeContext.fillStyle = `#${colour.toString(16).padStart(6, '0')}`;

    if (Number.isFinite(scaleY) && Math.abs(scaleY - 1) > Number.EPSILON) {
        const o = Math.max(1, resolution);

        largeContext.setTransform(o, 0, 0, o * o * scaleY, 0, o * lift * (1 - o * scaleY));
    }

    largeContext.fillText(text, GUTTER, baseline, maxWidth);

    outputContext.setTransform(1, 0, 0, 1, 0, 0);
    outputContext.clearRect(0, 0, outputCanvas.width, outputCanvas.height);
    outputContext.imageSmoothingEnabled = true;
    outputContext.imageSmoothingQuality = 'high';
    outputContext.drawImage(largeCanvas as CanvasImageSource, 0, 0, outputCanvas.width, outputCanvas.height);

    return { canvas: outputCanvas, width, height };
};
15Sujets
1Dépôts
20Membres
2En ligne
Profile
Inscrit
Dernière visite
Messages
0
Votes positifs
0
Votes négatifs
0

Nous utilisons des cookies pour assurer le bon fonctionnement du site et améliorer votre expérience. en savoir plus

Autoriser