Post
JA EN

Handing a Font from One VS Code Extension to Another: Inventing a Contract Where No Official Mechanism Exists, and Refitting Fullwidth Glyphs by Measurement

Handing a Font from One VS Code Extension to Another: Inventing a Contract Where No Official Mechanism Exists, and Refitting Fullwidth Glyphs by Measurement
  • Who this is for: Developers who write VS Code extensions, and anyone interested in the practical licensing side of modifying and redistributing fonts
  • Assumed knowledge: VS Code webviews, CSS @font-face, and the difference between a glyph’s advance and its ink (explained below)
  • Reading time: about 20 minutes

What this article is: an implementation record for a VS Code extension I build and publish, “Tmux Opener,” plus the five font extensions that supply fonts to it. It does not cover how to install or use any of that. That belongs to the companion article, “Keeping tmux resident in the VS Code sidebar,” and if you only want to fix Japanese text in your terminal, read that one instead. From here on this is about contract design and glyph transformation. The numbers are measured in this repository unless noted; external behavior (the xterm.js source, the Unicode spec, license terms) is quoted from public sources. Read it not as general best practice but as one example of where things break when you invent a contract in a place that has no official one.

Overview

Say you want to use an arbitrary font inside a VS Code webview. The straightforward route is to ship the font file in your own extension and write an @font-face. But what if you want to borrow that font from a different extension? There are ordinary reasons to want this: licensing may push you to split typefaces into separate packages, or you may not want several megabytes of TTF in every release of the main extension.

That is where you get stuck. VS Code has no official mechanism for sharing fonts between extensions1. Contribution points that reference font files do exist (contributes.icons and contributes.productIconThemes), but both are for single-color icon glyphs that VS Code itself consumes, not for supplying a text font to another extension. The font-related values a webview can reach are also limited to CSS variables like --vscode-editor-font-family, which just reflect editor settings.

So I wrote an unofficial contract. A font extension (one that ships a TTF and nothing else, no UI, no commands) declares a custom top-level field called providedFont in its package.json, and the consuming extension reads it through extensions.getExtension() and builds an @font-face from it. Once it was built, the dangerous part turned out not to be loading the font. It was that when the two sides of the contract disagree, nothing happens. Every place where a mismatch is asymptomatic will break silently unless you put a machine check on it.

The other problem is width. When xterm.js finds that a character it counted as one cell wide has an outline wider than 1.5 times the cell width (rounded up), it shrinks the glyph horizontally only2. Ambiguous-width3 symbols in Japanese fonts, like , hit that condition and come out squashed flat. So I converted the source fonts. The outline width gets pulled below the threshold, the height lost in that shrink gets recovered by stretching vertically only, and the cell width itself never moves, while the advance of the targeted glyphs is rewritten to exactly one cell. The ceiling on how far the vertical stretch may go is not a taste call: it is measured from the top and bottom of the font’s own outline.

This article covers the places in the contract design where a mismatch is asymptomatic, why I kept a static registry, what I actually measured for the width conversion, and how licensing constrains the name of a modified font.

Background: terminal cells, advance, and ink

Three terms first.

A cell is the grid slot where a terminal places a character, and its width comes from the advance of “A”. The advance is how far the cursor moves after drawing a character. The ink is the area the outline actually paints, and it is independent of the advance: ink can be narrower or wider than the advance. (I will call it “the outline” from here.)

The important part is that the terminal does not look at the font’s advance when deciding column counts. How many cells a character occupies comes from its Unicode width property, and that is where ambiguity enters. UAX #11 assigns each character one of six width values (Ambiguous, Fullwidth, Halfwidth, Narrow, Wide, Neutral), and characters marked Ambiguous resolve to either fullwidth or halfwidth depending on context3. Symbols that were fullwidth in East Asian legacy character sets became halfwidth elsewhere, a historical accident that means the code point alone does not determine the width.

is one of these ambiguous characters. The terminal (specifically the Unicode 11 provider in xterm.js) counts it as one column. A Japanese font, meanwhile, draws it with a fullwidth advance. Measuring Mgen+ 1mn: unitsPerEm is 1024, the cell width (the advance of A) is 512, and the advance of is a full 1024.

So a symbol the font drew assuming two cells gets pushed into a one-cell frame. The outline arrives at fullwidth size too, so it spills well past the frame. Everything below sits on top of that.

Problem A: a contract for passing a font between extensions

The contract field

The supplying extension declares this at the top level of its package.json.

1
2
3
4
5
6
7
8
9
10
"providedFont": {
  "family": "Mgen+ 1mn Term",
  "file": "fonts/MgenPlus1mnTerm-Regular.ttf",
  "weight": "400",
  "faces": [
    { "file": "fonts/MgenPlus1mnTerm-Regular.ttf", "weight": "400" },
    { "file": "fonts/MgenPlus1mnTerm-Bold.ttf", "weight": "700" }
  ],
  "license": "OFL-1.1"
}

You declare either a faces array (multiple faces) or a flat file plus weight pair (single face). If faces is present, the flat pair is ignored. The example above carries both because of a repository convention: keep the flat alias around for older readers. The contract does not require it.

The consumer uses it like this.

flowchart TB
    A["Font extension<br>ships only a TTF"] --> B["providedFont in<br>package.json"]
    B --> C["Consumer reads it via<br>getExtension"]
    C --> D["Build a webview URI<br>from extensionUri"]
    D --> E["Inject @font-face<br>into the webview"]
    E --> F["Not installed:<br>fall back to OS monospace"]

license is required and must be an SPDX identifier, but the code never reads the value. It exists to tell a human deciding whether to borrow the typeface what the terms are, and the license text itself ships inside the package. Requiring a field nobody reads makes sense because the contract is also an agreement between people.

Do not inject the contract’s family into CSS

This was the most counterintuitive thing I implemented. @font-face declares a family name; it does not read one out of the font file. CSS font matching never consults the name table inside the file. So the string used for injection can be a constant the consumer owns, and it should be.

Two reasons.

First, a value read from another extension’s package.json is a string that extension controls. The contract’s validity rule (a non-empty string) does not exclude CSS metacharacters. If a supplier crafts a family and the consumer interpolates it into a <style>, that is a CSS injection path. A constant in my own registry is a value I control, so the path does not exist.

That angle paid off later. A font name a user writes into settings had a path straight into a <style> that the xterm side builds by string concatenation, and I fixed it to check the token structure before passing it through. Settings values come from the user, which is a different trust boundary than the supplier, but the conclusion was the same: check every path by which a string reaches a stylesheet.

Second, precisely because injection does not read it, a mismatch between the contract’s family and the consumer’s constant is asymptomatic. Resolution, injection, and rendering all succeed while the two declarations disagree. Humans cannot notice something with no symptom, so I put a machine check on it. The test compares both directions: that every declared providedFont.family appears in the consumer’s registry (containment), and that every family in the registry has a declaring package whose string matches (equivalence).

Enumerate every place where the two sides can disagree with no visible effect, and put a test on all of them. That was the most tedious and most necessary work in the whole custom contract.

The degradation is quiet, but only on one side

If the supplying extension is not installed, the consumer injects no @font-face at all and the specification falls back to the OS monospace. This is not a silent fall: if the user has selected a bundled family, a one-time notification appears telling them installing the extension will make it work. (That is one of the reasons the registry is static, which comes up later in this section.)

The quiet side is the other one: when some of the declared faces are missing. If a package declares bold but does not ship it, the rendering is indistinguishable from a package that never declared bold, because bold cells get synthesized. The repository’s brake is a test that reconciles every declared face against the source tree on disk. But there is no mechanical layer checking that the contents of the published VSIX match the declaration, and that part remains a human step at packaging time. The README discloses this asymmetry.

localResourceRoots does not protect anything outside the webview

Here is a pitfall I nearly stepped into. The code that confirms a declared font file exists runs in the extension host, not in a webview, and localResourceRoots is no defense there. It is access control for webview resource delivery, and the extension host’s fs calls sit outside it.

Uri.joinPath also resolves ... Splitting the file value on / neutralizes absolute paths, but it does not stop upward traversal. If you stat a declared path, you have to first confirm the resolved absolute path is inside the supplier’s extensionUri, or a contract like "file": "../../../../etc/shadow" becomes an oracle for probing the existence of files outside the supplying extension.

To be honest, this extension does not have that containment check yet. I accept it on a trust-boundary argument (suppliers are limited to a fixed set of extension IDs, the five in the registry hardcoded into the consumer), but measured against the contract’s own normative text, it falls short. To avoid making an unimplemented check look implemented, I wrote the gap into the contract document at the relevant clause. Recording your own shortfalls in a design document turns them into something a later reader, and you, can actually fix.

Why the registry stayed static

The consumer’s font list is hardcoded. It could be dynamic: walk vscode.extensions.all and find every extension declaring providedFont. So why not?

There were three reasons, and dynamic discovery only removes one of them.

The settings enum (the list of font names a user can pick) is read before activate() runs. There is no runtime API to extend it, so a scan result cannot become a choice in that list.

The second is the feature that suggests a font extension when it is not installed. That needs a mapping from “this family name” to “this extension ID.” An uninstalled extension does not appear in a scan, so discovery cannot supply this in principle.

Only the remaining one (reading the contract out of installed extensions) can be replaced by a scan, and even then, adding a font would still require a release on the host side. Fonts do not get added often, so I kept the static registry and the suggestion feature.

“Could be dynamic” and “what dynamic actually removes” are different questions. Trading the simplicity of the registry for a change that removes one of three problems was not worth it.

Problem B: fullwidth symbols get squashed horizontally

What is happening

The relevant function in xterm.js reads as follows2.

1
2
3
4
5
6
7
8
9
export function allowRescaling(codepoint: number | undefined, width: number, glyphSizeX: number, deviceCellWidth: number): boolean {
  return (
    width === 1 &&
    glyphSizeX > Math.ceil(deviceCellWidth * 1.5) &&
    codepoint !== undefined && codepoint > 0xFF &&
    !isEmoji(codepoint) &&
    !isPowerlineGlyph(codepoint) && !isNerdFontGlyph(codepoint)
  );
}

Read it like this: rescaling is permitted when the character was counted as one cell wide, its outline is wider than 1.5 times the device cell width (rounded up), its code point is above ASCII, and it is not an emoji, Powerline, or Nerd Font glyph. The rescale then applies horizontally only. Vertical size stays, so the symbol is drawn flattened.

in a Japanese font satisfies all of that, because the terminal counts one cell while the outline arrives at fullwidth size.

What the conversion changes

The conversion touches three things: the advance, the outline width, and the height.

The advance is set to the cell width for the selected target glyphs only. A symbol that was drawn with a fullwidth advance becomes one cell wide (1024 to 512 for Mgen+ 1mn, 1080 to 540 for ShirokumaGen Term; in the latter, 1656 glyphs in Regular and 1621 in Bold are affected). The cell width itself (the advance of A) is never touched by the conversion, so the terminal’s grid does not move no matter what. Only the target glyphs move.

The outline width is shrunk to exactly 1.15 times the cell width, and only for glyphs that exceed that. The 1.5 ceiling on this ratio is not preference but a mechanical wall: cross it and the code above squashes the glyph horizontally. That does not make exactly 1.5 safe, either, because the check happens on rasterized device pixels and includes antialiasing bleed. Backing off one step from the 1.5 wall, I think somewhere around 1.3 is a reasonable operational limit, and the actual default is a more conservative 1.15. The conversion tool rejects any setting above 1.5 or at or below 0 before writing a single byte.

Whatever exceeds 1 spills symmetrically to both sides because the glyph is centered. Measured on ShirokumaGen Term, the overhang stays at 41 units at most against a 540-unit cell, about 7.6%. So this ratio is not containment within the cell, it is an upper bound on the overhang. A symbol may touch its neighbor’s cell slightly, but it never reaches the threshold that gets it squashed.

Height is the next problem. Shrinking the outline width costs the same proportion of height. Measured on Mgen+ 1mn Regular (cell width 512), goes from 928×928 to 588×588, which is 1.15 times 512, and ends up shorter than the capital A on the same face (748). Horizontal room is gone (the glyph already exceeds the cell width), so the only room left is vertical. I set the vertical factor to 1.3 times the horizontal factor. That 1.3 is a different quantity from the outline-width ratio, and it is a value I chose, not a value I derived. I baked five steps from 1.0 up to the ceiling and picked by eye. There is a measurable reference nearby (748, the capital A height, divided by 588, the shrunken symbol, gives 1.272), but the number was not derived from it.

The ceiling is measured from the font itself

Stretching vertically needs a ceiling so glyphs do not bite into the lines above and below. Fixing a constant here is risky, because the distribution of symbol heights varies by typeface.

So I measured the top and bottom of the font’s own (U+6F22) outline and used it as the reference glyph. In Mgen+ 1mn Regular it is y = −78..843, and the value differs per font and per face. Each glyph’s stretch factor is clamped so its post-stretch top and bottom stay inside that range (the lower bound is 1.0, so nothing ends up smaller than before). A font without this reference glyph offers no way to measure the ceiling, so the conversion refuses to write a single byte rather than substituting a guess.

What gets constrained is not the outline height but how far the glyph reaches into the line. The conversion preserves the vertical center, so an implementation that pushes only the height under a ceiling lets a glyph whose center differs from ’s escape the line by the stretched amount. I confirmed that by measurement and then changed the implementation to clamp the factor instead. (With the height-based approach, some glyphs went 32 units deeper at the bottom without the clamp ever firing, and others rose 26 units at the top even when it did.)

What measurements support are conditional statements

This was the hardest part to write as prose. What you want to write is “symbols in this typeface never go outside ,” and that sentence is false. The upstream typeface already contains symbols that reach higher or deeper than . In Mgen+ 1mn Regular, 141 of the 1001 conversion targets were already outside before any conversion happened.

The true statement only exists in conditional form.

This conversion never moves a glyph outside the y range of . Glyphs already outside before conversion do not move by even one unit.

For the same reason, “converting stretches symbols vertically” cannot be written as a universal either. Because the clamp’s lower bound is 1.0, glyphs already at the ceiling before conversion do not move. In ShirokumaGen Term Regular, 1128 of the 1723 conversion targets were stretched vertically; the rest had no room and stayed at 1.0.

Having measurements and being able to write sentences your measurements support are two different skills. The moment you restate a measured number as an unconditional universal, you are asserting something you did not measure.

What is left alone

ASCII and Latin-1 are out of scope. The terminal does not rescale them, and converting them would mean rebuilding the look of body text. Box drawing characters, block elements, and the BMP private use area (U+E000..U+F8FF) are also excluded. The conversion touches the advance, outline width, and height of the target glyphs and changes nothing else.

Making it reproducible

The conversion settings live in a single JSON file. That file is the normative source for what the ratios mean, what the ceiling is, and what the defaults are; the conversion script and every package README point at it rather than restating the meaning. If the same value is explained in two places, one of them going stale is a matter of time.

Upstream archives are recorded with a URL and a sha256, and every face is verified in one pass before conversion. A mix-up fails before a single byte is written. For packages where the upstream provenance was never recorded in the first place, I write down that it is not recorded, with the reason, rather than filling the gap with a guess.

Licensing: you do not get to pick the name of a modified font

When you modify a font and redistribute it, the name becomes a licensing question. Section 3 of the SIL Open Font License 1.1 says4:

No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.

A modified version cannot call itself by a name the font reserves. The restriction applies to the primary font name shown to users, which is exactly where naming decisions land. So before you decide on your own name, you have to determine which names are reserved. Upstream can add reserved names in a new release, which makes this a task you redo on every upstream update.

Among the packages I distribute, the converted ones carry a ` Term` suffix. That suffix makes two claims about the terminal grid: that a width-1 code point advances exactly one cell, and that its outline stays inside the range the contract sets (within 1.15 times the cell, centered). Note that the second one is an upper bound, not containment. Unconverted packages do not take the suffix, so the name tells you whether a package is conversion output.

Whether to keep a word from the upstream name I decided by meaning. The Console in HackGen Console is upstream’s mark for a terminal-oriented edition, which overlaps with what ` Term says, so I dropped it (ShirokumaGen Term). The Code in M PLUS 1 Code names which typeface it is (the monospaced version of the proportional M PLUS 1), so I kept it (M PLUS 1 Code Term). The 1mn in Mgen+ 1mn is upstream's name for an edition, where m marks monospace, but that is only one of the two facts Term` asserts, so I kept it. A word that overlaps only partly does not get dropped.

PlemolJP Console HS ships unmodified, with upstream’s bytes under upstream’s name. The reason is on the width side: in this typeface the advance of the target symbols already matches the cell, so there is nothing to convert.

Reserved names came back here in a different form. This typeface includes "PlemolJP" as a reserved name, so had it been a conversion target, the mechanical rule of appending ` Term` would have put a name containing a reserved name on the Marketplace. The decision not to convert came first and the hazard disappeared, so in terms of ordering I just got lucky. That is why I run the reserved-name comparison as a check independent of the naming rule.

Design the check so it cannot emit a quiet OK

The reserved-name comparison runs as a script, not by eye. The design decision that mattered was distinguishing kinds of OK by exit code.

  • 0 means “compared against at least one reserved name, no violation”
  • 3 means “the chosen name contains a reserved name” (a §3 violation)
  • 4 means “there were zero things to compare against, so the OK is vacuously true”
  • 5 means “held” (for example, the name matches a reserved name once whitespace is removed)
  • 1 means “the check did not complete”

4 and 5 exist because an OK nobody can distinguish from a real pass is the route by which a detector goes quiet. Returning 0 with empty stderr looks identical from the caller’s side whether four names were compared or none were. A check that found no reserved names at all is not a pass, it is “could not measure.” Give the vacuous OK its own code and require an explicit flag to accept it. Once the script worked this way, transcribing its result into a README started to mean something.

Wrapping up

When you invent a contract where no official mechanism exists, the danger is not that a feature fails to work. It is that both sides of the contract disagree and everything still succeeds. Three places in this design were asymptomatic: a mismatch between the contract’s family and the consumer’s constant, a declared face missing from the VSIX, and a check passing after finding zero reserved names. All three render fine, and all three pass the tests you did not write.

What the width conversion taught me was how to handle measurements. Having numbers is not the same as being able to write sentences those numbers support. “Never goes outside ” is false; “this conversion does not move anything outside, and does not move what was already outside” is true. Conditional sentences are harder to read, but when the unconditional version is false, there is no choice to make.

Both of these look like work you would skip for a tool only you use. Except the contract sits between someone else’s extension and mine, the font is a modified version of a typeface someone else made, and the name is bound by a license. Even building alone, there are parties who need the contract and the checks.

If you came looking for how to use it: this article covered the font side. The extension’s own features (keeping tmux resident in the VS Code sidebar, turning an AI CLI’s terminal bell into a native notification) and its setup are covered in the companion article, “Keeping tmux resident in the VS Code sidebar: building a workspace that never drops an AI CLI’s ‘done’.”

References

Sources are listed in the order of the citation numbers used in the text.

Other sources (not cited by number in the text)

  • ShirokumaLibrary/tmux-opener - the source repository for the implementation discussed here. The providedFont contract is specified in font-packages/README.md, and the conversion settings live in scripts/font-fit/manifest.json. [Reliability: high (primary source)]
  • HackGen - yuru7. Upstream for ShirokumaGen Term. [Reliability: high]
  • PlemolJP - yuru7. Upstream for the package shipped unmodified. [Reliability: high]
  1. Contribution Points / Webview API - Visual Studio Code Extension API. The list of contribution points has no entry for supplying a font to another extension (contributes.icons and contributes.productIconThemes reference font files, but both are for icon glyphs consumed by VS Code itself). The font-related values a webview can reference are limited to CSS variables reflecting editor settings. [Reliability: high] ↩︎

  2. xterm.js allowRescaling (src/browser/renderer/shared/RendererUtils.ts) - xterm.js. The code in the text is quoted from that file (upstream inline comments omitted). [Reliability: high] ↩︎ ↩︎2

  3. UAX #11: East Asian Width - Unicode Consortium. The six width values, and the context-dependent resolution of Ambiguous characters. [Reliability: high] ↩︎ ↩︎2

  4. SIL Open Font License Version 1.1 - 26 February 2007 - SIL International. The §3 clause is quoted from that document (both sentences in full). [Reliability: high] ↩︎

This post is licensed under CC BY 4.0 by the author.