diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md index a7c04cccd..0b8099e21 100644 --- a/ci/release/changelogs/next.md +++ b/ci/release/changelogs/next.md @@ -1,5 +1,6 @@ #### Features 🚀 +- Flags to set a custom font are supported. See [docs](https://d2lang.com/todo). [#1108](https://github.com/terrastruct/d2/pull/1108) - `--animate-interval` can be passed as a flag to animate multi-board diagrams. See [docs](https://d2lang.com/todo). [#1088](https://github.com/terrastruct/d2/pull/1088) - `paper` is available as a `fill-pattern` option [#1070](https://github.com/terrastruct/d2/pull/1070) - fonts are now subsetted to reduce svg file size [#1089](https://github.com/terrastruct/d2/pull/1089) diff --git a/ci/release/template/man/d2.1 b/ci/release/template/man/d2.1 index f3c0d6303..81bc4e895 100644 --- a/ci/release/template/man/d2.1 +++ b/ci/release/template/man/d2.1 @@ -80,6 +80,15 @@ Renders the diagram to look like it was sketched by hand .It Fl -center Ar flag Center the SVG in the containing viewbox, such as your browser screen .Ns . +.It Fl -font-regular +Path to .ttf file to use for the regular font. If none provided, Source Sans Pro Regular is used +.Ns . +.It Fl -font-italic +Path to .ttf file to use for the italic font. If none provided, Source Sans Pro Regular-Italic is used +.Ns . +.It Fl -font-bold +Path to .ttf file to use for the bold font. If none provided, Source Sans Pro Bold is used +.Ns . .It Fl -pad Ar 100 Pixels padded around the rendered diagram .Ns . diff --git a/d2cli/main.go b/d2cli/main.go index b10e7f64e..ae2446c95 100644 --- a/d2cli/main.go +++ b/d2cli/main.go @@ -95,6 +95,10 @@ func Run(ctx context.Context, ms *xmain.State) (err error) { return err } + fontRegularFlag := ms.Opts.String("D2_FONT_REGULAR", "font-regular", "", "", "path to .ttf file to use for the regular font. If none provided, Source Sans Pro Regular is used.") + fontItalicFlag := ms.Opts.String("D2_FONT_ITALIC", "font-italic", "", "", "path to .ttf file to use for the italic font. If none provided, Source Sans Pro Regular-Italic is used.") + fontBoldFlag := ms.Opts.String("D2_FONT_BOLD", "font-bold", "", "", "path to .ttf file to use for the bold font. If none provided, Source Sans Pro Bold is used.") + ps, err := d2plugin.ListPlugins(ctx) if err != nil { return err @@ -114,6 +118,11 @@ func Run(ctx context.Context, ms *xmain.State) (err error) { return nil } + fontFamily, err := loadFonts(ms, *fontRegularFlag, *fontItalicFlag, *fontBoldFlag) + if err != nil { + return xmain.UsageErrorf("failed to load specified fonts: %v", err) + } + if len(ms.Opts.Flags.Args()) > 0 { switch ms.Opts.Flags.Arg(0) { case "init-playwright": @@ -265,6 +274,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) { bundle: *bundleFlag, forceAppendix: *forceAppendixFlag, pw: pw, + fontFamily: fontFamily, }) if err != nil { return err @@ -275,7 +285,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) { ctx, cancel := context.WithTimeout(ctx, time.Minute*2) defer cancel() - _, written, err := compile(ctx, ms, plugin, renderOpts, *animateIntervalFlag, inputPath, outputPath, *bundleFlag, *forceAppendixFlag, pw.Page) + _, written, err := compile(ctx, ms, plugin, renderOpts, fontFamily, *animateIntervalFlag, inputPath, outputPath, *bundleFlag, *forceAppendixFlag, pw.Page) if err != nil { if written { return fmt.Errorf("failed to fully compile (partial render written): %w", err) @@ -285,7 +295,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) { return nil } -func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, renderOpts d2svg.RenderOpts, animateInterval int64, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page) (_ []byte, written bool, _ error) { +func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, renderOpts d2svg.RenderOpts, fontFamily *d2fonts.FontFamily, animateInterval int64, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page) (_ []byte, written bool, _ error) { start := time.Now() input, err := ms.ReadPath(inputPath) if err != nil { @@ -299,9 +309,10 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, rende layout := plugin.Layout opts := &d2lib.CompileOptions{ - Layout: layout, - Ruler: ruler, - ThemeID: renderOpts.ThemeID, + Layout: layout, + Ruler: ruler, + ThemeID: renderOpts.ThemeID, + FontFamily: fontFamily, } if renderOpts.Sketch { opts.FontFamily = go2.Pointer(d2fonts.HandDrawn) @@ -659,3 +670,47 @@ func initPlaywright() error { } return pw.Cleanup() } + +func loadFont(ms *xmain.State, path string) ([]byte, error) { + if filepath.Ext(path) != ".ttf" { + return nil, fmt.Errorf("expected .ttf file but %s has extension %s", path, filepath.Ext(path)) + } + ttf, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read font at %s: %v", path, err) + } + ms.Log.Info.Printf("font %s loaded", filepath.Base(path)) + return ttf, nil +} + +func loadFonts(ms *xmain.State, pathToRegular, pathToItalic, pathToBold string) (*d2fonts.FontFamily, error) { + if pathToRegular == "" && pathToItalic == "" && pathToBold == "" { + return nil, nil + } + + var regularTTF []byte + var italicTTF []byte + var boldTTF []byte + + var err error + if pathToRegular != "" { + regularTTF, err = loadFont(ms, pathToRegular) + if err != nil { + return nil, err + } + } + if pathToItalic != "" { + italicTTF, err = loadFont(ms, pathToItalic) + if err != nil { + return nil, err + } + } + if pathToBold != "" { + boldTTF, err = loadFont(ms, pathToBold) + if err != nil { + return nil, err + } + } + + return d2fonts.AddFontFamily("custom", regularTTF, italicTTF, boldTTF) +} diff --git a/d2cli/watch.go b/d2cli/watch.go index 8f3e3c398..d1a2c64f0 100644 --- a/d2cli/watch.go +++ b/d2cli/watch.go @@ -26,6 +26,7 @@ import ( "oss.terrastruct.com/util-go/xmain" "oss.terrastruct.com/d2/d2plugin" + "oss.terrastruct.com/d2/d2renderers/d2fonts" "oss.terrastruct.com/d2/d2renderers/d2svg" "oss.terrastruct.com/d2/lib/png" ) @@ -51,6 +52,7 @@ type watcherOpts struct { bundle bool forceAppendix bool pw png.Playwright + fontFamily *d2fonts.FontFamily } type watcher struct { @@ -358,7 +360,7 @@ func (w *watcher) compileLoop(ctx context.Context) error { w.pw = newPW } - svg, _, err := compile(ctx, w.ms, w.layoutPlugin, w.renderOpts, w.animateInterval, w.inputPath, w.outputPath, w.bundle, w.forceAppendix, w.pw.Page) + svg, _, err := compile(ctx, w.ms, w.layoutPlugin, w.renderOpts, w.fontFamily, w.animateInterval, w.inputPath, w.outputPath, w.bundle, w.forceAppendix, w.pw.Page) errs := "" if err != nil { if len(svg) > 0 { diff --git a/d2renderers/d2fonts/d2fonts.go b/d2renderers/d2fonts/d2fonts.go index a8e50d979..a0ae866ae 100644 --- a/d2renderers/d2fonts/d2fonts.go +++ b/d2renderers/d2fonts/d2fonts.go @@ -245,3 +245,78 @@ var D2_FONT_TO_FAMILY = map[string]FontFamily{ "default": SourceSansPro, "mono": SourceCodePro, } + +func AddFontStyle(font Font, style FontStyle, ttf []byte) error { + FontFaces[font] = ttf + + woff, err := fontlib.Sfnt2Woff(ttf) + if err != nil { + return fmt.Errorf("failed to encode ttf to woff: %v", err) + } + encodedWoff := fmt.Sprintf("data:application/font-woff;base64,%v", base64.StdEncoding.EncodeToString(woff)) + FontEncodings[font] = encodedWoff + + return nil +} + +func AddFontFamily(name string, regularTTF, italicTTF, boldTTF []byte) (*FontFamily, error) { + customFontFamily := FontFamily(name) + + regularFont := Font{ + Family: customFontFamily, + Style: FONT_STYLE_REGULAR, + } + if regularTTF != nil { + err := AddFontStyle(regularFont, FONT_STYLE_REGULAR, regularTTF) + if err != nil { + return nil, err + } + } else { + fallbackFont := Font{ + Family: SourceSansPro, + Style: FONT_STYLE_REGULAR, + } + FontFaces[regularFont] = FontFaces[fallbackFont] + FontEncodings[regularFont] = FontEncodings[fallbackFont] + } + + italicFont := Font{ + Family: customFontFamily, + Style: FONT_STYLE_ITALIC, + } + if italicTTF != nil { + err := AddFontStyle(italicFont, FONT_STYLE_ITALIC, italicTTF) + if err != nil { + return nil, err + } + } else { + fallbackFont := Font{ + Family: SourceSansPro, + Style: FONT_STYLE_ITALIC, + } + FontFaces[italicFont] = FontFaces[fallbackFont] + FontEncodings[italicFont] = FontEncodings[fallbackFont] + } + + boldFont := Font{ + Family: customFontFamily, + Style: FONT_STYLE_BOLD, + } + if boldTTF != nil { + err := AddFontStyle(boldFont, FONT_STYLE_BOLD, boldTTF) + if err != nil { + return nil, err + } + } else { + fallbackFont := Font{ + Family: SourceSansPro, + Style: FONT_STYLE_BOLD, + } + FontFaces[boldFont] = FontFaces[fallbackFont] + FontEncodings[boldFont] = FontEncodings[fallbackFont] + } + + FontFamilies = append(FontFamilies, customFontFamily) + + return &customFontFamily, nil +} diff --git a/e2etests-cli/RockSalt-Regular.ttf b/e2etests-cli/RockSalt-Regular.ttf new file mode 100644 index 000000000..0f72fe6d9 Binary files /dev/null and b/e2etests-cli/RockSalt-Regular.ttf differ diff --git a/e2etests-cli/main_test.go b/e2etests-cli/main_test.go index 3a2edc3ce..402ce7f3e 100644 --- a/e2etests-cli/main_test.go +++ b/e2etests-cli/main_test.go @@ -82,6 +82,19 @@ steps: { assert.Testdata(t, ".svg", svg) }, }, + { + name: "with-font", + run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) { + writeFile(t, dir, "font.d2", `a: Why do computers get sick often? +b: Because their Windows are always open! +a -> b: italic font +`) + err := runTestMain(t, ctx, dir, env, "--font-bold=./RockSalt-Regular.ttf", "font.d2") + assert.Success(t, err) + svg := readFile(t, dir, "font.svg") + assert.Testdata(t, ".svg", svg) + }, + }, { name: "incompatible-animation", run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) { diff --git a/e2etests-cli/testdata/TestCLI_E2E/with-font.exp.svg b/e2etests-cli/testdata/TestCLI_E2E/with-font.exp.svg new file mode 100644 index 000000000..8a25746ac --- /dev/null +++ b/e2etests-cli/testdata/TestCLI_E2E/with-font.exp.svg @@ -0,0 +1,102 @@ +Why do computers get sick often?Because their Windows are always open! italic font + + +