├── AUTHORS.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── bella.png ├── cmd └── bella-png │ └── main.go ├── go.mod ├── go.sum └── internal ├── approve └── image.go └── render ├── binsearch.go ├── binsearch_test.go ├── render.go ├── render_test.go └── testdata ├── .gitignore └── TestText ├── %2E.good.png ├── Hello,_world.good.png ├── multi_line.good.png ├── tiny.good.png └── way_too_small_to_read.good.png /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # The Bella Authors 2 | 3 | This file lists the authors of the Bella software package, 4 | and the individual contributors for where the copyright owner is a 5 | corporation. 6 | 7 | By adding your name and email address into this file, you agree to the 8 | Developer Certificate of Origin and other contribution criteria 9 | indicated in the file `CONTRIBUTING.md`. 10 | 11 | If your contribution is copyrighted by a corporation, use the form 12 | `Firstname Lastname for Example, Inc`. 13 | 14 | Please keep the list sorted (bytewise lexicographical order, each line 15 | as-is, ignoring locales). 16 | 17 | - Tommi Virtanen 18 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We welcome contributions, please create a Github pull request at 4 | https://github.com/tv42/bella. If your intended contribution is more 5 | than a small bugfix, we ask that you first open an issue to discuss 6 | the design. 7 | 8 | To keep legal matters clear, we use the DCO concept from the Linux 9 | Kernel project. Since this is a smaller project without the 10 | organizational structure of Linux, we use a one-time addition of your 11 | name to the list of authors, instead of a repeated Signed-Off-By line 12 | in every commit. Additionally, we don't want the overhead of restating 13 | the license in every file, the top level `LICENSE` file suffices. 14 | 15 | 16 | Please read and agree to the following: 17 | 18 | Contributions are licensed by their authors under the MIT license as 19 | indicated in the file `LICENSE`. Contributors certify the following: 20 | 21 | ``` 22 | Developer Certificate of Origin 23 | Version 1.1 24 | 25 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 26 | 660 York Street, Suite 102, 27 | San Francisco, CA 94110 USA 28 | 29 | Everyone is permitted to copy and distribute verbatim copies of this 30 | license document, but changing it is not allowed. 31 | 32 | 33 | Developer's Certificate of Origin 1.1 34 | 35 | By making a contribution to this project, I certify that: 36 | 37 | (a) The contribution was created in whole or in part by me and I 38 | have the right to submit it under the open source license 39 | indicated in the file; or 40 | 41 | (b) The contribution is based upon previous work that, to the best 42 | of my knowledge, is covered under an appropriate open source 43 | license and I have the right under that license to submit that 44 | work with modifications, whether created in whole or in part 45 | by me, under the same open source license (unless I am 46 | permitted to submit under a different license), as indicated 47 | in the file; or 48 | 49 | (c) The contribution was provided directly to me by some other 50 | person who certified (a), (b) or (c) and I have not modified 51 | it. 52 | 53 | (d) I understand and agree that this project and the contribution 54 | are public and that a record of the contribution (including all 55 | personal information I submit with it, including my sign-off) is 56 | maintained indefinitely and may be redistributed consistent with 57 | this project or the open source license(s) involved. 58 | ``` 59 | 60 | Record your agreement by adding your legal name and email address to 61 | the file `AUTHORS.md` as the only change in your first commit, and 62 | make sure the you use the same name and address for your Git 63 | authorship information. 64 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 The Bella Authors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bella -- label printer software 2 | 3 | ![`bel la`](bella.png) 4 | 5 | Bella renders text to graphics and prints it on a label maker using IPP/CUPS. 6 | 7 | It was created for use with a [Dymo LabelManager PnP](https://smile.amazon.com/gp/product/B00464E5P2/), but other continuous-tape printers might work too. 8 | If you have a different brand/model printer, let us know how to get it working! 9 | 10 | **Current status: just getting started on the project.** 11 | 12 | ``` 13 | bella-png >bella.png 'bel la' 14 | lp -d label -o landscape bella.png 15 | ``` 16 | 17 | ## Roadmap 18 | 19 | - command-line printing 20 | - web interface, especially for mobile client use 21 | - submit print jobs via IPP? 22 | - limit label maximum width (mm or inch), to e.g. fit on drawer front 23 | - maybe embed images? 24 | - maybe embed QR codes? 25 | 26 | ## Supported outputs and tapes 27 | 28 | We currently assume the output needs to be 64 pixels tall. 29 | This matches the printable area of a 1/2-inch Dymo D1 tape. 30 | 31 | ### Dymo D1 tape widths 32 | 33 | | Tape size (inch) | Tape size (mm, approx) | Printable area (mm) | Printable area (pixels) | 34 | |------------------|------------------------|---------------------|-------------------------| 35 | | 1/2 | 12 | 9 | 64 | 36 | | 3/8 | 9 | ? | ? | 37 | | 1/4 | 6 | ? | ? | 38 | 39 | 40 | ## Setting up CUPS for Dymo (on Debian/Ubuntu) 41 | 42 | See https://www.baitando.com/it/2017/12/12/install-dymo-labelwriter-on-headless-linux 43 | 44 | ``` 45 | sudo apt-get install printer-driver-dymo 46 | 47 | # for me, the above didn't include the PPD file for some reason; get it from the SDK 48 | wget http://download.dymo.com/dymo/Software/Download%20Drivers/Linux/Download/dymo-cups-drivers-1.4.0.tar.gz 49 | tar xzf dymo-cups-drivers-1.4.0.tar.gz 50 | sudo cp dymo-cups-drivers-1.4.0.5/ppd/lmpnp.ppd /usr/share/cups/model/ 51 | 52 | # find your device serial number in lsusb, it's a 14-digit number 53 | lsusb -d 0922:1002 -v 54 | lpadmin -p label -v 'usb://DYMO/LabelManager%20PnP?serial=REPLACE_SERIAL_HERE' -P /usr/share/cups/model/lmpnp.ppd 55 | cupsenable label 56 | 57 | # test it 58 | convert -size 300x64 canvas:white -font 'Times-Roman' -pointsize 64 -fill black -draw 'text 0,64 "label maker"' label.png 59 | lp -d label -o landscape label.png 60 | ``` 61 | 62 | 63 | ## Open questions 64 | 65 | ### How many levels of gray can a Dymo print? 66 | 67 | We're currently using 8-bit grayscale. Is there any point in using 16-bit? 68 | 69 | ### Automatic cutting 70 | 71 | Do label printers with automatic cutting need something special? 72 | Right now we just print it out, cutting is end user problem (and the Dymo LabelManager PnP has a manual cutter); this means if you print multiple labels in a row, you lose the use of the built-in cutter and need scissors. 73 | You can configure the Dymo LabelManager PnP to pause after each label, to leave you time to cut, but I haven't tested if it actually waits for user action or not. 74 | 75 | 76 | ## Anti-goals 77 | 78 | - Fixed size pre-cut labels. We'd rather get the continuous tape style labels working *great*, and that means letting the length of the printout be whatever it is. 79 | - Generating PDF. The printer driver will just rasterize it anyway, so we can simply submit a bitmap. The size isn't an issue here. 80 | 81 | 82 | ## Alternatives 83 | 84 | - You could use the Windows and macOS software that came with the label maker. 85 | - You could make bitmaps with ImageMagick: https://unix.stackexchange.com/questions/138804/how-to-transform-a-text-file-into-a-picture 86 | 87 | 88 | ## Resources 89 | 90 | - You'll need the Dymo SDK to get the `PPD` file: https://www.dymo.com/en-US/dymo-label-sdk-cups-linux-p (that page is horribly broken, but the "Download" link works). 91 | Announcement at . 92 | Fluffy landing page at . 93 | Unofficial mirrors at https://github.com/matthiasbock/dymo-cups-drivers and https://github.com/Kyle-Falconer/DYMO-SDK-for-Linux and https://github.com/xcross/dymo-cups-drivers 94 | 95 | ### Random related things, not endorsing just cataloging 96 | 97 | - https://www.linux-magazine.com/Issues/2016/183/Perl-Producing-Labels 98 | - The HID device and USB mode switch stuff seems to be handled fine by `printer-driver-dymo` these days, you no longer need the reverse-engineered `dymoprint`: 99 | https://sbronner.com/dymoprint.html 100 | https://github.com/computerlyrik/dymoprint 101 | https://randomfoo.net/2018/07/09/printing-with-a-dymo-labelmanager-pnp-on-linux 102 | or this alternate implementation https://github.com/Firedrake/dymo-labelmanager 103 | - https://glabels.org/ or https://github.com/jimevins/glabels-qt seems more for pre-cut stickers ([mostly](https://github.com/jimevins/glabels-qt/commit/467ca9fc624e07442d45b0214f2cccb4919004a4)) 104 | - https://kwagjj.wordpress.com/2017/05/10/dymo-linux-command-line/ talks more about gLabels and its batch mode, which I do not wish to use. 105 | - Something in C# https://github.com/ChaliceAriel/LabelManager 106 | - Something in Python https://github.com/richardbarlow/hacky-dymo-label-gen 107 | - dymoprint wrapper https://github.com/matrach/iot-labeler that seems to contain no actual code 108 | -------------------------------------------------------------------------------- /bella.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tv42/bella/42d81584a247a38647c3ccc63e58b50e1c00e444/bella.png -------------------------------------------------------------------------------- /cmd/bella-png/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "image/png" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | 12 | "eagain.net/go/bella/internal/render" 13 | "golang.org/x/sys/unix" 14 | ) 15 | 16 | func main() { 17 | prog := filepath.Base(os.Args[0]) 18 | log.SetFlags(0) 19 | log.SetPrefix(prog + ": ") 20 | 21 | flag.Usage = func() { 22 | fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", prog) 23 | fmt.Fprintf(flag.CommandLine.Output(), " %s [OPTS] LINES.. >DEST_PNG\n", prog) 24 | fmt.Fprintln(flag.CommandLine.Output()) 25 | flag.PrintDefaults() 26 | } 27 | 28 | var height int 29 | flag.IntVar(&height, "height", 64, "Height of image") 30 | flag.Parse() 31 | 32 | if flag.NArg() == 0 { 33 | log.Print("missing text to print") 34 | os.Exit(2) 35 | } 36 | 37 | if _, err := unix.IoctlGetTermios(unix.Stdout, unix.TCGETS); err == nil { 38 | // is a terminal 39 | log.Fatal("stdout is a terminal, refusing to output binary") 40 | } 41 | 42 | text := strings.Join(flag.Args(), "\n") 43 | img, err := render.Text(text, 44 | render.WithMaxHeight(height), 45 | ) 46 | if err != nil { 47 | log.Fatal(err) 48 | } 49 | 50 | if err := png.Encode(os.Stdout, img); err != nil { 51 | log.Fatalf("png encode: %v", err) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module eagain.net/go/bella 2 | 3 | go 1.15 4 | 5 | require ( 6 | golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 7 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 h1:QelT11PB4FXiDEXucrfNckHoFxwt8USGY1ajP1ZF5lM= 2 | golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 3 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= 4 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 5 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 6 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 7 | -------------------------------------------------------------------------------- /internal/approve/image.go: -------------------------------------------------------------------------------- 1 | package approve 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "image" 7 | "image/color" 8 | "image/png" 9 | "os" 10 | "os/exec" 11 | "path" 12 | "path/filepath" 13 | "runtime" 14 | "strings" 15 | "testing" 16 | ) 17 | 18 | func colorEq(a, b color.Color) bool { 19 | ar, ag, ab, aa := a.RGBA() 20 | br, bg, bb, ba := b.RGBA() 21 | return ar == br && ag == bg && ab == bb && aa == ba 22 | } 23 | 24 | func Image(t testing.TB, img image.Image) error { 25 | if img == nil { 26 | return errors.New("image is nil") 27 | } 28 | callers := make([]uintptr, 1) 29 | n := runtime.Callers(2, callers) 30 | if n < 1 { 31 | return errors.New("unknown caller") 32 | } 33 | frames := runtime.CallersFrames(callers) 34 | frame, _ := frames.Next() 35 | 36 | if frame.File == "" { 37 | return fmt.Errorf("caller file not known: %+v", frame) 38 | } 39 | sourceDir := filepath.Dir(frame.File) 40 | testdata := path.Join(sourceDir, "testdata") 41 | testname := t.Name() 42 | // sanitize subtest names for filesystem use 43 | testname = strings.ReplaceAll(testname, "%", "%25") 44 | testname = strings.ReplaceAll(testname, ".", "%2E") 45 | 46 | newName := path.Join(testdata, testname+".new.png") 47 | if err := os.Mkdir(filepath.Dir(newName), 0755); err != nil && !errors.Is(err, os.ErrExist) { 48 | return fmt.Errorf("cannot create directory: %w", err) 49 | } 50 | newF, err := os.Create(newName) 51 | if err != nil { 52 | return fmt.Errorf("cannot open PNG for saving: %v", err) 53 | } 54 | defer newF.Close() 55 | if err := png.Encode(newF, img); err != nil { 56 | return fmt.Errorf("cannot save PNG: %v: %v", newName, err) 57 | } 58 | if err := newF.Close(); err != nil { 59 | return fmt.Errorf("cannot finish saving PNG: %v", err) 60 | } 61 | 62 | goodName := path.Join(testdata, testname+".good.png") 63 | goodF, err := os.Open(goodName) 64 | if err != nil { 65 | return fmt.Errorf("cannot open good file: %v", err) 66 | } 67 | defer goodF.Close() 68 | good, err := png.Decode(goodF) 69 | if err != nil { 70 | return fmt.Errorf("cannot load good PNG: %v: %v", goodName, err) 71 | } 72 | 73 | if g, e := good.Bounds().Size(), img.Bounds().Size(); !g.Eq(e) { 74 | return fmt.Errorf("size mismatch: %v != %v", g, e) 75 | } 76 | 77 | if err := strictEq(img, good); err == nil { 78 | return nil 79 | } 80 | // try a fuzzy match 81 | // 82 | // If you know of a Go library implementing a perceptual diff (not 83 | // just a perceptual hash), please tell me! 84 | cmd := exec.Command("perceptualdiff", newName, goodName) 85 | if buf, err := cmd.CombinedOutput(); err != nil { 86 | return fmt.Errorf("perceptualdiff failed:\n%s%s", buf, err) 87 | } 88 | return nil 89 | } 90 | 91 | // strictEq is a strict pixel by pixel comparison. It assumes the 92 | // images are the same size. 93 | func strictEq(img, good image.Image) error { 94 | off := img.Bounds().Min.Sub(good.Bounds().Min) 95 | for y := good.Bounds().Min.Y; y < good.Bounds().Max.Y; y++ { 96 | for x := good.Bounds().Min.X; x < good.Bounds().Max.X; x++ { 97 | colorGood := good.At(x, y) 98 | p := image.Point{X: x, Y: y}.Add(off) 99 | colorGot := img.At(p.X, p.Y) 100 | if !colorEq(colorGood, colorGot) { 101 | return fmt.Errorf("pixel difference at %v: %v != %v", p, colorGot, colorGood) 102 | } 103 | } 104 | } 105 | return nil 106 | } 107 | -------------------------------------------------------------------------------- /internal/render/binsearch.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "log" 5 | "math" 6 | "sort" 7 | 8 | "golang.org/x/image/font" 9 | "golang.org/x/image/math/fixed" 10 | ) 11 | 12 | func binsearch(lines int, maxHeight int, fontFn FontFunc) (font.Face, error) { 13 | const debug = false 14 | // One inch tall text, 2.54cm, should definitely be more than your 15 | // label maker tape is wide. 16 | const max = 72.0 17 | // Adjustment size, since this is floats not ints. Having this be 18 | // tiny adds very little cost to the logarithmic search, and helps 19 | // with my perfectionism. 20 | const epsilon = 0.01 21 | 22 | // sort.Search looks for smallest i=[0,n) for which fn(i) is true. 23 | // We want to find the largest size which still fit inside 24 | // constraints. Search for smallest point size that fails to fit, 25 | // then go one down from that. 26 | n := int(math.Ceil(max / epsilon)) 27 | iToPoints := func(i int) float64 { 28 | // constrain input to >=0 so we don't have to worry about 29 | // negative numbers in the degenerate case where 30 | // sort.Search()==0. 31 | return math.Max(float64(i), 0) * epsilon 32 | } 33 | var faceErr error 34 | matchFn := func(i int) bool { 35 | size := iToPoints(i) 36 | face, err := fontFn(size) 37 | if err != nil { 38 | // awkward early abort 39 | faceErr = err 40 | return true 41 | } 42 | metrics := face.Metrics() 43 | // As per 44 | // https://developer.apple.com/library/archive/documentation/TextFonts/Conceptual/CocoaTextArchitecture/Art/glyph_metrics_2x.png 45 | // (via https://godoc.org/golang.org/x/image/font#Metrics), 46 | height := (metrics.Ascent + 47 | metrics.Height.Mul(fixed.I(lines-1)) + 48 | metrics.Descent).Ceil() 49 | face.Close() 50 | 51 | if height > maxHeight { 52 | if debug { 53 | log.Printf("size=%-5g big %v > %v", size, height, maxHeight) 54 | } 55 | return true 56 | } 57 | if debug { 58 | log.Printf("size=%-5g ok %v <= %v", size, height, maxHeight) 59 | } 60 | return false 61 | } 62 | tooBig := sort.Search(n, matchFn) 63 | if faceErr != nil { 64 | return nil, faceErr 65 | } 66 | size := iToPoints(tooBig - 1) 67 | face, err := fontFn(size) 68 | if err != nil { 69 | return nil, err 70 | } 71 | return face, nil 72 | } 73 | -------------------------------------------------------------------------------- /internal/render/binsearch_test.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "golang.org/x/image/font" 8 | "golang.org/x/image/font/gofont/goregular" 9 | "golang.org/x/image/font/opentype" 10 | "golang.org/x/image/math/fixed" 11 | ) 12 | 13 | func TestBinSearch(t *testing.T) { 14 | ttf, err := opentype.Parse(goregular.TTF) 15 | if err != nil { 16 | t.Fatalf("cannot parse Go Regular font: %v", err) 17 | } 18 | fontFn := func(size float64) (font.Face, error) { 19 | face, err := opentype.NewFace(ttf, &opentype.FaceOptions{ 20 | Size: size, 21 | DPI: 72, 22 | }) 23 | return face, err 24 | } 25 | 26 | check := func(t *testing.T, lines int, maxHeight int, wantAscent, wantDescent fixed.Int26_6) { 27 | face, err := binsearch(lines, maxHeight, fontFn) 28 | if err != nil { 29 | t.Fatalf("binsearch: %v", err) 30 | } 31 | // point size is lost (cannot be retrieved from *opentype.Face), 32 | // so compare other font metrics to make sure we picked the right 33 | // one. 34 | if g, e := face.Metrics().Ascent, wantAscent; g != e { 35 | t.Errorf("wrong ascent: %v != %v", g, e) 36 | } 37 | if g, e := face.Metrics().Descent, wantDescent; g != e { 38 | t.Errorf("wrong descent: %v != %v", g, e) 39 | } 40 | } 41 | 42 | t.Run("lines=1", func(t *testing.T) { 43 | run := func(maxHeight int, wantAscent, wantDescent fixed.Int26_6) { 44 | t.Run(fmt.Sprintf("height=%d", maxHeight), func(t *testing.T) { 45 | check(t, 1, maxHeight, wantAscent, wantDescent) 46 | }) 47 | } 48 | run(10, fixed.I(8)|11, fixed.I(1)|53) 49 | run(20, fixed.I(16)|22, fixed.I(3)|42) 50 | run(30, fixed.I(24)|33, fixed.I(5)|30) 51 | run(40, fixed.I(32)|45, fixed.I(7)|19) 52 | run(50, fixed.I(40)|56, fixed.I(9)|8) 53 | run(60, fixed.I(49)|3, fixed.I(10)|61) 54 | }) 55 | 56 | t.Run("lines=2", func(t *testing.T) { 57 | run := func(maxHeight int, wantAscent, wantDescent fixed.Int26_6) { 58 | t.Run(fmt.Sprintf("height=%d", maxHeight), func(t *testing.T) { 59 | check(t, 2, maxHeight, wantAscent, wantDescent) 60 | }) 61 | } 62 | run(20, fixed.I(8)|11, fixed.I(1)|53) 63 | run(30, fixed.I(12)|17, fixed.I(2)|47) 64 | run(40, fixed.I(16)|22, fixed.I(3)|42) 65 | run(50, fixed.I(20)|28, fixed.I(4)|36) 66 | run(60, fixed.I(24)|33, fixed.I(5)|30) 67 | run(70, fixed.I(28)|39, fixed.I(6)|25) 68 | }) 69 | 70 | t.Run("lines=3", func(t *testing.T) { 71 | run := func(maxHeight int, wantAscent, wantDescent fixed.Int26_6) { 72 | t.Run(fmt.Sprintf("height=%d", maxHeight), func(t *testing.T) { 73 | check(t, 3, maxHeight, wantAscent, wantDescent) 74 | }) 75 | } 76 | run(20, fixed.I(5)|29, fixed.I(1)|14) 77 | run(30, fixed.I(8)|11, fixed.I(1)|53) 78 | run(40, fixed.I(10)|57, fixed.I(2)|28) 79 | run(50, fixed.I(13)|39, fixed.I(3)|2) 80 | run(60, fixed.I(16)|22, fixed.I(3)|42) 81 | run(70, fixed.I(19)|5, fixed.I(4)|17) 82 | }) 83 | } 84 | -------------------------------------------------------------------------------- /internal/render/render.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "image/color" 7 | "image/draw" 8 | "strings" 9 | 10 | "golang.org/x/image/font" 11 | "golang.org/x/image/font/gofont/goregular" 12 | "golang.org/x/image/font/opentype" 13 | "golang.org/x/image/math/fixed" 14 | ) 15 | 16 | type Option option 17 | 18 | type config struct { 19 | maxHeight int 20 | imageFn ImageFunc 21 | fontFn FontFunc 22 | } 23 | 24 | type option func(*config) 25 | 26 | // WithMaxHeight sets maximum height for rendered text, in pixels. 27 | // 28 | // This option is mandatory. 29 | func WithMaxHeight(h int) Option { 30 | if h <= 0 { 31 | panic(fmt.Sprintf("WithMaxHeight: impossible height %d", h)) 32 | } 33 | opt := func(cfg *config) { 34 | cfg.maxHeight = h 35 | } 36 | return opt 37 | } 38 | 39 | type ImageFunc func(image.Rectangle) draw.Image 40 | 41 | // WithImageFunc sets the function used to create an image of the 42 | // desired size. 43 | // 44 | // By default, image.NewGray is used. 45 | func WithImageFunc(fn ImageFunc) Option { 46 | opt := func(cfg *config) { 47 | cfg.imageFn = fn 48 | } 49 | return opt 50 | } 51 | 52 | // FontFunc returns a new font face of the given point size. 53 | // 54 | // Points are 1/72 of an inch. 55 | // https://en.wikipedia.org/wiki/Point_(typography) 56 | type FontFunc func(size float64) (font.Face, error) 57 | 58 | // WithFont sets the font used. 59 | // 60 | // By default, the Go Regular font is used. 61 | func WithFont(fn FontFunc) Option { 62 | opt := func(cfg *config) { 63 | cfg.fontFn = fn 64 | } 65 | return opt 66 | } 67 | 68 | // WithFontOpenType is a convenience helper for WithFont that uses 69 | // an OpenType font. 70 | // 71 | // Unlike opentype.NewFace, a FaceOptions value must be provided. 72 | // FaceOptions.Size is overwritten by this function. 73 | func WithFontOpenType(f *opentype.Font, opts *opentype.FaceOptions) Option { 74 | fn := func(size float64) (font.Face, error) { 75 | opts.Size = size 76 | face, err := opentype.NewFace(f, opts) 77 | if err != nil { 78 | return nil, err 79 | } 80 | return face, nil 81 | } 82 | return WithFont(fn) 83 | } 84 | 85 | // Text renders text as large as possible, without exceeding the 86 | // maximum size. 87 | func Text(text string, opts ...Option) (image.Image, error) { 88 | var cfg config 89 | for _, opt := range opts { 90 | opt(&cfg) 91 | } 92 | if cfg.maxHeight == 0 { 93 | panic("render.Text: missing option WithMaxHeight") 94 | } 95 | if cfg.imageFn == nil { 96 | cfg.imageFn = func(rect image.Rectangle) draw.Image { 97 | return image.NewGray(rect) 98 | } 99 | } 100 | if cfg.fontFn == nil { 101 | ttf, err := opentype.Parse(goregular.TTF) 102 | if err != nil { 103 | return nil, fmt.Errorf("error parsing font Go Regular: %w", err) 104 | } 105 | opt := WithFontOpenType(ttf, &opentype.FaceOptions{ 106 | DPI: 72, 107 | Hinting: font.HintingFull, 108 | }) 109 | opt(&cfg) 110 | } 111 | return renderText(text, &cfg) 112 | } 113 | 114 | func renderText(text string, cfg *config) (image.Image, error) { 115 | const debugConstraintMaxHeight = true 116 | 117 | lines := strings.Split(text, "\n") 118 | face, err := binsearch(len(lines), cfg.maxHeight, cfg.fontFn) 119 | if err != nil { 120 | return nil, err 121 | } 122 | defer face.Close() 123 | 124 | d := &font.Drawer{ 125 | // Dst will be set later, once we know the right bounds 126 | 127 | Src: image.NewUniform(color.Black), 128 | Face: face, 129 | // Adjust starting point to avoid negative bounding box 130 | // coordinates. 131 | Dot: fixed.Point26_6{X: 0, Y: face.Metrics().Ascent}, 132 | } 133 | var maxWidth int 134 | for _, line := range lines { 135 | bounds, _ := d.BoundString(line) 136 | if bounds.Max.X.Ceil() > maxWidth { 137 | maxWidth = bounds.Max.X.Ceil() 138 | } 139 | } 140 | d.Dst = cfg.imageFn(image.Rect(0, 0, maxWidth, cfg.maxHeight)) 141 | 142 | // it starts out as all black, set a better background 143 | draw.Draw(d.Dst, d.Dst.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) 144 | for _, line := range lines { 145 | 146 | if debugConstraintMaxHeight { 147 | bounds, _ := d.BoundString(line) 148 | if bounds.Max.Y.Ceil() > cfg.maxHeight { 149 | return nil, fmt.Errorf("bounds spilled: %v..%v", bounds.Min, bounds.Max) 150 | } 151 | } 152 | 153 | d.DrawString(line) 154 | d.Dot.X = 0 155 | d.Dot.Y = d.Dot.Y + face.Metrics().Height 156 | } 157 | return d.Dst, nil 158 | } 159 | -------------------------------------------------------------------------------- /internal/render/render_test.go: -------------------------------------------------------------------------------- 1 | package render_test 2 | 3 | import ( 4 | "image" 5 | "testing" 6 | 7 | "eagain.net/go/bella/internal/approve" 8 | "eagain.net/go/bella/internal/render" 9 | "golang.org/x/image/font" 10 | "golang.org/x/image/font/gofont/goregular" 11 | "golang.org/x/image/font/opentype" 12 | ) 13 | 14 | func mustPanic(t testing.TB, fn func()) { 15 | defer func() { 16 | p := recover() 17 | if p == nil { 18 | t.Fatal("expected a panic") 19 | } 20 | t.Logf("got expected panic: %v", p) 21 | }() 22 | fn() 23 | } 24 | 25 | func TestTextOptsNoHeight(t *testing.T) { 26 | mustPanic(t, func() { render.Text("foo") }) 27 | } 28 | 29 | func TestText(t *testing.T) { 30 | ttf, err := opentype.Parse(goregular.TTF) 31 | if err != nil { 32 | t.Fatalf("opentype.Parse: %v", err) 33 | } 34 | run := func(text string, maxHeight int) { 35 | t.Run(text, func(t *testing.T) { 36 | img, err := render.Text( 37 | text, 38 | render.WithMaxHeight(maxHeight), 39 | render.WithFontOpenType( 40 | ttf, 41 | &opentype.FaceOptions{ 42 | DPI: 180, 43 | Hinting: font.HintingFull, 44 | }, 45 | ), 46 | ) 47 | if err != nil { 48 | t.Fatalf("render.Text: %v", err) 49 | } 50 | if g, e := img.Bounds(), image.Rect(0, 0, img.Bounds().Max.X, maxHeight); g != e { 51 | t.Errorf("wrong bounds: %v != %v", g, e) 52 | } 53 | if err := approve.Image(t, img); err != nil { 54 | t.Errorf("not approved: %v", err) 55 | } 56 | }) 57 | } 58 | 59 | run("Hello, world", 42) 60 | run(".", 64) 61 | run("tiny", 10) 62 | run("multi\nline", 64) 63 | run("way\ntoo\nsmall\nto\nread", 64) 64 | } 65 | -------------------------------------------------------------------------------- /internal/render/testdata/.gitignore: -------------------------------------------------------------------------------- 1 | *.new.png 2 | -------------------------------------------------------------------------------- /internal/render/testdata/TestText/%2E.good.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tv42/bella/42d81584a247a38647c3ccc63e58b50e1c00e444/internal/render/testdata/TestText/%2E.good.png -------------------------------------------------------------------------------- /internal/render/testdata/TestText/Hello,_world.good.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tv42/bella/42d81584a247a38647c3ccc63e58b50e1c00e444/internal/render/testdata/TestText/Hello,_world.good.png -------------------------------------------------------------------------------- /internal/render/testdata/TestText/multi_line.good.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tv42/bella/42d81584a247a38647c3ccc63e58b50e1c00e444/internal/render/testdata/TestText/multi_line.good.png -------------------------------------------------------------------------------- /internal/render/testdata/TestText/tiny.good.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tv42/bella/42d81584a247a38647c3ccc63e58b50e1c00e444/internal/render/testdata/TestText/tiny.good.png -------------------------------------------------------------------------------- /internal/render/testdata/TestText/way_too_small_to_read.good.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tv42/bella/42d81584a247a38647c3ccc63e58b50e1c00e444/internal/render/testdata/TestText/way_too_small_to_read.good.png --------------------------------------------------------------------------------