diff --git a/docs/badge-typography.md b/docs/badge-typography.md new file mode 100644 index 0000000..3e25b64 --- /dev/null +++ b/docs/badge-typography.md @@ -0,0 +1,47 @@ +# Badge Typography + +PolicyChecks badges use proportional text measurement so labels with wide and narrow characters receive consistent horizontal spacing. Badge widths should come from the rendered text, not from character count and not from per-badge adjustments. + +## Rendering Rule + +The SVG renderer measures text with 11 px `Verdana` metrics and renders it with the existing Verdana-first font stack. It calculates each label and message segment independently: + +```text +text width = ceil(sum of glyph advance widths) +segment width = max(44 px, text width + 10 px) +``` + +The additional 10 px provides a 5 px inset on each side. Very short strings may receive more space because every segment remains at least 44 px wide. + +Each `` element is centered within its segment and includes both `textLength` and `lengthAdjust="spacing"`. Those attributes preserve the intended text width when a browser uses a fallback font. + +The implementation is in [`src/badges/svg.ts`](../src/badges/svg.ts). + +## Adding Another Badge + +New PolicyChecks badges inherit this typography automatically. Define the badge's `label` and optional `badgeMessage`, then add its definition to [`src/badges/registry.ts`](../src/badges/registry.ts). Do not pad labels with spaces, choose a fixed image width, or introduce per-badge padding. + +To reproduce the same treatment in another SVG renderer: + +1. Measure text using the same font family and size used in the SVG. +2. Add 10 px to the measured width and retain a sensible minimum segment width. +3. Center the text at the midpoint of its segment. +4. Set `textLength` to the measured width and use `lengthAdjust="spacing"`. + +The current embedded metrics cover printable ASCII. A badge that displays other characters should add measurements for those glyphs or use a font-measurement library; otherwise, the renderer uses a conservative fallback width. + +## Verification + +Run the complete project check: + +```bash +npm run check +``` + +For visual inspection, start the deterministic fixture server with `npm run dev:fixtures`, then open a badge such as: + +```text +http://localhost:3000/github/example/project/sha-pinning-required.svg +``` + +Regression coverage in [`test/badges.test.ts`](../test/badges.test.ts) verifies the 5 px inset and confirms that equal-length strings containing different glyphs receive different widths. diff --git a/src/badges/svg.ts b/src/badges/svg.ts index 88adc4a..c2cad78 100644 --- a/src/badges/svg.ts +++ b/src/badges/svg.ts @@ -7,11 +7,30 @@ const colorHex = { lightgrey: "#9f9f9f" } as const; +const asciiStart = 32; +const fallbackCharacterWidth = 11; +const horizontalPadding = 10; +const minimumSegmentWidth = 44; + +// Browser-measured advance widths for printable ASCII in 11 px Verdana. Keeping +// the text length explicit also makes the spacing stable when a fallback font is +// used to display the SVG. +const verdana11AsciiWidths = [ + 3.87, 4.33, 5.05, 9, 6.99, 11.84, 7.99, 2.95, 5, 5, 6.99, 9, 4, 5, 4, 5, 6.99, 6.99, 6.99, 6.99, + 6.99, 6.99, 6.99, 6.99, 6.99, 6.99, 5, 5, 9, 9, 9, 6, 11, 7.52, 7.54, 7.68, 8.48, 6.96, 6.32, + 8.53, 8.27, 4.63, 5, 7.62, 6.12, 9.27, 8.23, 8.66, 6.63, 8.66, 7.65, 7.52, 6.78, 8.05, 7.52, + 10.88, 7.54, 6.77, 7.54, 5, 5, 5, 9, 6.99, 6.99, 6.61, 6.85, 5.73, 6.85, 6.55, 3.87, 6.85, 6.96, + 3.02, 3.79, 6.51, 3.02, 10.7, 6.96, 6.68, 6.85, 6.85, 4.69, 5.73, 4.33, 6.96, 6.51, 9, 6.51, 6.51, + 5.78, 6.98, 5, 6.98, 9 +] as const; + export function renderBadgeSvg(definition: BadgeDefinition, result: BadgeResult): string { const label = definition.label; const message = messageForResult(definition, result); - const labelWidth = textWidth(label); - const messageWidth = textWidth(message); + const labelTextWidth = textWidth(label); + const messageTextWidth = textWidth(message); + const labelWidth = segmentWidth(labelTextWidth); + const messageWidth = segmentWidth(messageTextWidth); const width = labelWidth + messageWidth; const messageX = labelWidth + messageWidth / 2; const color = svgColor(colorForResult(definition, result)); @@ -31,16 +50,25 @@ export function renderBadgeSvg(definition: BadgeDefinition, result: BadgeResult) - ${escapeXml(label)} - ${escapeXml(label)} - ${escapeXml(message)} - ${escapeXml(message)} + ${escapeXml(label)} + ${escapeXml(label)} + ${escapeXml(message)} + ${escapeXml(message)} `; } function textWidth(text: string): number { - return Math.max(44, Math.ceil(text.length * 7 + 10)); + const measuredWidth = [...text].reduce((total, character) => { + const index = character.codePointAt(0)! - asciiStart; + return total + (verdana11AsciiWidths[index] ?? fallbackCharacterWidth); + }, 0); + + return Math.ceil(measuredWidth); +} + +function segmentWidth(measuredTextWidth: number): number { + return Math.max(minimumSegmentWidth, measuredTextWidth + horizontalPadding); } function svgColor(color: string): string { diff --git a/test/badges.test.ts b/test/badges.test.ts index 0db7f70..1ed41c0 100644 --- a/test/badges.test.ts +++ b/test/badges.test.ts @@ -60,6 +60,32 @@ describe("badge renderers", () => { expect(renderBadgeSvg(communityHealthBadge, communityResult)).toContain("#6cc613"); }); + it("uses proportional text metrics with consistent horizontal padding", () => { + const definition = { + ...shaPinningRequiredBadge, + label: "secret push protection" + }; + const svg = renderBadgeSvg(definition, result("enabled")); + + expect(svg).toContain('width="188" height="20"'); + expect(svg).toContain('width="134" height="20" fill="#555"'); + expect(svg).toContain('x="67" y="14" textLength="124" lengthAdjust="spacing"'); + expect(svg).toContain('x="161" y="14" textLength="44" lengthAdjust="spacing"'); + }); + + it("allocates more width to wider glyphs in equal-length labels", () => { + const narrowSvg = renderBadgeSvg( + { ...shaPinningRequiredBadge, label: "iiiiiiii" }, + result("enabled") + ); + const wideSvg = renderBadgeSvg( + { ...shaPinningRequiredBadge, label: "WWWWWWWW" }, + result("enabled") + ); + + expect(svgWidth(wideSvg)).toBeGreaterThan(svgWidth(narrowSvg)); + }); + it("renders unknown community health when no valid score is available", () => { expect(toShieldsJson(communityHealthBadge, result("unknown"))).toMatchObject({ message: "unknown", @@ -101,3 +127,7 @@ function result(result: BadgeResult["result"]): BadgeResult { details: {} }; } + +function svgWidth(svg: string): number { + return Number(svg.match(/^]+ width="(\d+)"/)?.[1]); +}