From 412b50aaef08f5032f1eb05aeb07d2dbf57afdef Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 19 Oct 2026 22:19:22 +0200 Subject: [PATCH] officecanvas: record a laid-out page as editable office objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second paint.Canvas consumer, beside pdfcanvas: PaintVector draws the same layout through it, and it records what an editable slide is made of — filled shapes, gradient shapes (PaintVector's constant-opacity shadings merged back into one gradient, transparent across their gaps), one text box per line (runs split by face, size, colour, link, letter-spacing; the gap between words a space), pictures cropped to their clip — with the page background and the faces used: family and style from the 'name' table (with the OS/2 weight/slant for fonts that call every face Regular), embeddability from fsType, and SFNT, which reassembles the font file from its tables so the deck can embed the font the slide was laid out with. Shape.Render draws a gradient as a picture for formats whose gradients are poorer (ODF's two colours). Format-neutral, in device pixels: go-slides maps it to go-ooxml and go-odf. Co-Authored-By: Claude Opus 5.5 --- README.md | 17 ++ officecanvas/fonts.go | 182 ++++++++++++ officecanvas/officecanvas.go | 466 ++++++++++++++++++++++++++++++ officecanvas/officecanvas_test.go | 186 ++++++++++++ officecanvas/render.go | 122 ++++++++ 5 files changed, 973 insertions(+) create mode 100644 officecanvas/fonts.go create mode 100644 officecanvas/officecanvas.go create mode 100644 officecanvas/officecanvas_test.go create mode 100644 officecanvas/render.go diff --git a/README.md b/README.md index a50bbb8..223779f 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,22 @@ be selected; `stats.Rasterized` counts them. Laying out and painting are split so that pages can be laid out concurrently and painted, in order, into one document. +### Editable output (office decks) + +`officecanvas` is the other `paint.Canvas` consumer: it *records* a page as +the objects an editable office slide is made of — filled shapes (rectangles, +rounded rectangles, rings as outlines with holes), gradient shapes (a CSS +gradient's constant-opacity pieces merged back into one gradient, transparent +across the gaps), one text box per line of words (runs split by face, size, +colour, link and letter-spacing, the gap between words a space), and pictures +cropped to their clip — plus the page background and the faces used (family +and style from the font's `name` table, embeddability from its OS/2 `fsType`; +`SFNT` reassembles the very font file the page was laid out with). A writer +maps the `Page` to DrawingML or ODF; go-slides does both. A slide cannot clip, +so a picture is cropped to its clip and a gradient takes its clip's outline; +`Shape.Render` draws a gradient as a picture for a format whose gradients are +poorer. + There is also a CLI: ``` @@ -115,6 +131,7 @@ Fetch (go-browserhttp) → Parse (x/net/html → dom) → Cascade (css: +var | `js` | JavaScript execution via [goja](https://github.com/dop251/goja) bound to a minimal real DOM, with `fetch()`/XHR and laid-out-geometry read-back (`getBoundingClientRect`, `offset*`, `getComputedStyle`). | | `paint` | Rasterises the box tree to `*image.RGBA` — AA text (real bold + italic), gradients, border-radius, box-shadow, opacity, images and SVG; also the real `Measurer` (go-opentype faces). `PaintVector` draws the same tree through a vector `Canvas`. | | `pdfcanvas` | A `paint.Canvas` drawing into a go-pdfkit page: vector PDF with real text. | +| `officecanvas` | A `paint.Canvas` recording a page as editable office objects (shapes, gradients, text lines, pictures, fonts) for PPTX/ODP writers. | | `engine` (root) | `Fetch`, `Render`, `Screenshot`, `RenderHTML`, `RenderWithLinks`, `LayoutHTML` → `PageLayout` (paint as image or vectors), the settle-then-render loop, image + SVG loading, and the anchor hit-map. | | `cmd/render` | CLI: `render -url URL -out shot.png -w 1024 -h 768` (or `-file page.html`). | diff --git a/officecanvas/fonts.go b/officecanvas/fonts.go new file mode 100644 index 0000000..38530a7 --- /dev/null +++ b/officecanvas/fonts.go @@ -0,0 +1,182 @@ +// Copyright (c) the go-webengine/engine authors. +// SPDX-License-Identifier: BSD-3-Clause + +package officecanvas + +import ( + "encoding/binary" + "sort" + "strings" + "unicode/utf16" + + "github.com/go-opentype/opentype" +) + +// names reads a font's family (name ID 1) and subfamily (name ID 2) from its +// 'name' table, preferring the Windows Unicode records — the names an office +// application matches a run's typeface against (OpenType 'name' table; the +// legacy family, so that "Lato Black" stays a family of its own rather than +// a weight of "Lato" that a viewer would draw bold). +func names(f *opentype.Font) (family, sub string) { + t, ok := f.Table("name") + if !ok || len(t) < 6 { + return "", "" + } + count := int(binary.BigEndian.Uint16(t[2:])) + strOff := int(binary.BigEndian.Uint16(t[4:])) + var best [3]int // per name ID 1,2: score of the record kept + var out [3]string + for i := 0; i < count; i++ { + r := 6 + 12*i + if r+12 > len(t) { + break + } + platform := binary.BigEndian.Uint16(t[r:]) + encoding := binary.BigEndian.Uint16(t[r+2:]) + lang := binary.BigEndian.Uint16(t[r+4:]) + id := binary.BigEndian.Uint16(t[r+6:]) + length := int(binary.BigEndian.Uint16(t[r+8:])) + off := strOff + int(binary.BigEndian.Uint16(t[r+10:])) + if id < 1 || id > 2 || off+length > len(t) || length > 512 { + continue + } + raw := t[off : off+length] + score, s := 0, "" + switch { + case platform == 3 && (encoding == 1 || encoding == 10): + score, s = 2, utf16be(raw) + if lang == 0x0409 { + score = 3 + } + case platform == 0: + score, s = 2, utf16be(raw) + case platform == 1 && encoding == 0: + score, s = 1, latin1(raw) + } + if score > best[id] && s != "" { + best[id], out[id] = score, s + } + } + return out[1], out[2] +} + +func utf16be(b []byte) string { + u := make([]uint16, len(b)/2) + for i := range u { + u[i] = binary.BigEndian.Uint16(b[2*i:]) + } + return string(utf16.Decode(u)) +} + +func latin1(b []byte) string { + r := make([]rune, len(b)) + for i, c := range b { + r[i] = rune(c) + } + return string(r) +} + +// embeddable reads the OS/2 fsType (OpenType 'OS/2' table, offset 8): a font +// may go into an editable document when it is installable (0) or editable +// (bit 3); restricted (bit 1) and preview & print (bit 2) forbid it. A font +// without the table makes no restriction. +func embeddable(f *opentype.Font) bool { + t, ok := f.Table("OS/2") + if !ok || len(t) < 10 { + return true + } + fs := binary.BigEndian.Uint16(t[8:]) + if fs&0x0008 != 0 { + return true + } + return fs&0x0006 == 0 +} + +func lower(s string) string { return strings.ToLower(s) } +func contains(s, sub string) bool { return strings.Contains(s, sub) } + +// maxSFNTBytes bounds a reassembled font file. +const maxSFNTBytes = 32 << 20 + +// SFNT reassembles a parsed font into a font file — the offset table, the +// table directory with each table's checksum, the tables padded to four +// bytes — from the tables it carries (OpenType "Organization of an OpenType +// Font"), so that the very font a page was laid out with can be embedded +// whole. It returns nil for a font past 32 MiB. +func SFNT(f *opentype.Font) []byte { + tags := f.TableTags() + sort.Strings(tags) + type tbl struct { + tag string + data []byte + } + var tables []tbl + total := 12 + 16*len(tags) + cff := false + for _, tag := range tags { + if len(tag) != 4 { + continue + } + d, ok := f.Table(tag) + if !ok { + continue + } + if tag == "CFF " || tag == "CFF2" { + cff = true + } + tables = append(tables, tbl{tag, d}) + total += (len(d) + 3) &^ 3 + if total > maxSFNTBytes { + return nil + } + } + n := len(tables) + out := make([]byte, 12+16*n, total) + if cff { + copy(out, "OTTO") + } else { + binary.BigEndian.PutUint32(out, 0x00010000) + } + binary.BigEndian.PutUint16(out[4:], uint16(n)) + es := 0 + for 1<<(es+1) <= n { + es++ + } + sr := (1 << es) * 16 + binary.BigEndian.PutUint16(out[6:], uint16(sr)) + binary.BigEndian.PutUint16(out[8:], uint16(es)) + binary.BigEndian.PutUint16(out[10:], uint16(n*16-sr)) + headAt := -1 + for i, t := range tables { + off := len(out) + data := t.data + if t.tag == "head" && len(data) >= 12 { + data = append([]byte(nil), data...) + binary.BigEndian.PutUint32(data[8:], 0) // checkSumAdjustment, set below + headAt = off + } + out = append(out, data...) + for len(out)%4 != 0 { + out = append(out, 0) + } + d := out[12+16*i:] + copy(d, t.tag) + binary.BigEndian.PutUint32(d[4:], checksum(out[off:off+len(data)])) + binary.BigEndian.PutUint32(d[8:], uint32(off)) + binary.BigEndian.PutUint32(d[12:], uint32(len(data))) + } + if headAt >= 0 { + binary.BigEndian.PutUint32(out[headAt+8:], 0xB1B0AFBA-checksum(out)) + } + return out +} + +func checksum(b []byte) uint32 { + var s uint32 + for i := 0; i < len(b); i += 4 { + var w [4]byte + copy(w[:], b[i:]) + s += binary.BigEndian.Uint32(w[:]) + } + return s +} diff --git a/officecanvas/officecanvas.go b/officecanvas/officecanvas.go new file mode 100644 index 0000000..eda0a16 --- /dev/null +++ b/officecanvas/officecanvas.go @@ -0,0 +1,466 @@ +// Copyright (c) the go-webengine/engine authors. +// SPDX-License-Identifier: BSD-3-Clause + +// Package officecanvas records a laid-out page as the editable objects an +// office presentation is made of — filled shapes, gradient-filled shapes, +// lines of text in text boxes, and pictures — instead of drawing it. It is a +// paint.Canvas: PageLayout.PaintVector draws through it exactly as it draws a +// PDF page through pdfcanvas, so an editable slide has the layout of the +// image and PDF exports by construction. The recorded Page is format-neutral, +// in device pixels; a writer maps it to DrawingML (PresentationML) or to ODF +// draw elements. +// +// What a slide format cannot hold the paint layer already turned into +// pictures (an element under opacity, a filter, a blurred shadow); those +// arrive here as images, with their text as invisible runs over them. +// +// Things a slide cannot do that a page can: clip. A gradient or a picture +// inside a clip takes the clip's outline (a rounded box) or is cropped to its +// bounds; a filled shape ignores the clips around it, which are the overflow +// clips of its ancestors and cut nothing on a slide that fits. +package officecanvas + +import ( + "image" + "math" + + "github.com/go-opentype/opentype" + "github.com/go-webengine/engine/css" + "github.com/go-webengine/engine/paint" +) + +// Kind is what a recorded Shape is. +type Kind uint8 + +// The shapes. +const ( + KindFill Kind = iota // Path filled with Color + KindGradient // Path filled with Gradient + KindText // a line of Runs in Bounds + KindImage // Image stretched over Bounds, after Crop +) + +// Shape is one recorded object. Bounds is its box in device pixels. +type Shape struct { + Kind Kind + Bounds paint.Rect + + // Path is the outline of a fill or gradient, absolute, and EvenOdd its + // rule (overlaps are holes). Rect is set when the outline is Bounds + // itself. + Path *paint.Path + EvenOdd bool + Rect bool + + Color css.Color // KindFill + Gradient *Gradient // KindGradient + + Runs []Run // KindText + + Image image.Image // KindImage + // Crop is what to remove from the picture's left, top, right and + // bottom, as fractions, before it is stretched over Bounds. + Crop [4]float64 +} + +// Run is a stretch of a text line in one face, size and colour. Size is in +// device pixels. Invisible text is there to be found and copied over a +// picture of the same words; it is drawn with zero alpha. +type Run struct { + Text string + Face *Face + Size float64 + Color css.Color + Link string + Invisible bool + // Spacing is the space added after each character beyond its own + // advance (CSS letter-spacing), in device pixels. + Spacing float64 +} + +// Face is a font face the runs are set in. +type Face struct { + Font *opentype.Font + Family string // the name a document names it by (name ID 1) + Bold, Italic bool // its style within that family (name ID 2) + // Embeddable is false when the font's OS/2 fsType forbids embedding it + // in an editable document (restricted, or preview & print only). + Embeddable bool +} + +// Gradient is a gradient fill in office terms: stops along a direction +// across the shape's box, or around a centre. +// +// Linear: Angle is the direction of colour change in degrees clockwise from +// the x axis (y down), and the stops run across the projection of the box on +// that direction — DrawingML's unscaled a:lin, and CSS's gradient line. Radial: +// the stops run from Center (fractions of the box) outwards. +// +// Shadings are the paint-level shadings it was made from, for a writer that +// cannot express it and draws it as a picture (Shape.Render). +type Gradient struct { + Radial bool + Angle float64 + Center [2]float64 + Stops []paint.GradientStop + Shadings []paint.Shading +} + +// Page is what was drawn on one page. +type Page struct { + // Background is the colour of a fill covering the whole page drawn + // before anything else: the slide's background. Nil when there is none. + Background *css.Color + Shapes []Shape +} + +// Link is a clickable area: text drawn inside it links to Href. +type Link struct { + Rect image.Rectangle + Href string +} + +// Canvas records a page. Build one per page with New; it is not safe for +// concurrent use. +type Canvas struct { + page paint.Rect + links []Link + faces map[*opentype.Font]*Face + out Page + + clips []clip // the clip paths in force, innermost last + saves []int // len(clips) at each Save + + // gradOwner is the clip a run of shadings is being merged under. + gradOwner *paint.Path +} + +type clip struct { + path *paint.Path + evenOdd bool +} + +// New starts recording a page of the given device-pixel size. links are the +// page's clickable areas (engine.PageLayout.Links). faces, shared across the +// pages of one document, maps each font to its Face; nil gets a fresh map. +func New(page image.Rectangle, links []Link, faces map[*opentype.Font]*Face) *Canvas { + if faces == nil { + faces = map[*opentype.Font]*Face{} + } + return &Canvas{page: paint.Rect{X: float64(page.Min.X), Y: float64(page.Min.Y), W: float64(page.Dx()), H: float64(page.Dy())}, links: links, faces: faces} +} + +var _ paint.Canvas = (*Canvas)(nil) + +// Page returns what was recorded. +func (c *Canvas) Page() Page { return c.out } + +// Save implements paint.Canvas. +func (c *Canvas) Save() { c.saves = append(c.saves, len(c.clips)) } + +// Restore implements paint.Canvas. +func (c *Canvas) Restore() { + if len(c.saves) == 0 { + return + } + n := c.saves[len(c.saves)-1] + c.saves = c.saves[:len(c.saves)-1] + c.clips = c.clips[:n] +} + +// Clip implements paint.Canvas: the clip is remembered, to shape what is +// drawn in it. +func (c *Canvas) Clip(p *paint.Path, evenOdd bool) { + c.clips = append(c.clips, clip{path: p, evenOdd: evenOdd}) +} + +// Fill implements paint.Canvas. +func (c *Canvas) Fill(p *paint.Path, col css.Color, evenOdd bool) { + if p == nil || len(p.Ops) == 0 || col.A == 0 { + return + } + b, ok := bounds(p) + if !ok { + return + } + rect := isRect(p, b) + if rect && len(c.out.Shapes) == 0 && covers(b, c.page) { + bg := col + bg.A = 255 + c.out.Background = &bg + return + } + c.out.Shapes = append(c.out.Shapes, Shape{Kind: KindFill, Bounds: b, Path: p, EvenOdd: evenOdd, Rect: rect, Color: col}) +} + +// Shade implements paint.Canvas: the gradient fills the innermost clip's +// outline. PaintVector splits one CSS gradient into shadings of constant +// opacity, all under one clip; they are merged back into one gradient here, +// a stop per stop, transparent across the gaps between them. +func (c *Canvas) Shade(s paint.Shading) { + if len(s.Stops) < 2 || len(c.clips) == 0 { + return + } + cl := c.clips[len(c.clips)-1] + b, ok := bounds(cl.path) + if !ok || b.W <= 0 || b.H <= 0 { + return + } + var g *Gradient + if n := len(c.out.Shapes); n > 0 && c.gradOwner == cl.path && c.out.Shapes[n-1].Kind == KindGradient { + g = c.out.Shapes[n-1].Gradient + } else { + g = &Gradient{Radial: s.Radial} + if s.Radial { + g.Center = [2]float64{(s.CX - b.X) / b.W, (s.CY - b.Y) / b.H} + } else { + g.Angle = math.Atan2(s.Y1-s.Y0, s.X1-s.X0) * 180 / math.Pi + } + c.out.Shapes = append(c.out.Shapes, Shape{Kind: KindGradient, Bounds: b, Path: cl.path, EvenOdd: cl.evenOdd, Rect: isRect(cl.path, b), Gradient: g}) + c.gradOwner = cl.path + } + g.Shadings = append(g.Shadings, s) + pos := func(t float64) float64 { return positionOf(s, t, b) } + first, last := pos(s.Stops[0].Pos), pos(s.Stops[len(s.Stops)-1].Pos) + if n := len(g.Stops); n > 0 && first-g.Stops[n-1].Pos > 1e-4 { + // A gap: the previous run ended, nothing is painted until this one. + end := g.Stops[n-1] + end.Color.A = 0 + start := paint.GradientStop{Pos: first, Color: s.Stops[0].Color} + start.Color.A = 0 + g.Stops = append(g.Stops, end, start) + } else if n == 0 && !s.ExtendStart && first > 1e-4 { + start := paint.GradientStop{Pos: first, Color: s.Stops[0].Color} + start.Color.A = 0 + g.Stops = append(g.Stops, paint.GradientStop{Pos: 0, Color: start.Color}, start) + } + for _, st := range s.Stops { + g.Stops = append(g.Stops, paint.GradientStop{Pos: clamp01(pos(st.Pos)), Color: st.Color}) + } + if !s.ExtendEnd && last < 1-1e-4 { + end := s.Stops[len(s.Stops)-1].Color + end.A = 0 + g.Stops = append(g.Stops, paint.GradientStop{Pos: clamp01(last), Color: end}, paint.GradientStop{Pos: 1, Color: end}) + } +} + +// positionOf maps position t of shading s to the office gradient's own +// position: across the box's projection on the gradient's direction for a +// linear one, as a fraction of the radius for a radial one. +func positionOf(s paint.Shading, t float64, b paint.Rect) float64 { + if s.Radial { + return s.R0 + t*(s.R1-s.R0) + } + dx, dy := s.X1-s.X0, s.Y1-s.Y0 + l := math.Hypot(dx, dy) + if l == 0 { + return t + } + ux, uy := dx/l, dy/l + lo, hi := math.Inf(1), math.Inf(-1) + for _, p := range [][2]float64{{b.X, b.Y}, {b.X + b.W, b.Y}, {b.X, b.Y + b.H}, {b.X + b.W, b.Y + b.H}} { + d := p[0]*ux + p[1]*uy + lo, hi = math.Min(lo, d), math.Max(hi, d) + } + if hi <= lo { + return t + } + px, py := s.X0+t*dx, s.Y0+t*dy + return (px*ux + py*uy - lo) / (hi - lo) +} + +func clamp01(v float64) float64 { return math.Max(0, math.Min(1, v)) } + +// Image implements paint.Canvas: a picture, cropped to the innermost clip's +// bounds when the clip cuts it (a background image sized to cover its box). +func (c *Canvas) Image(img image.Image, dst paint.Rect) { + if img == nil || dst.W <= 0 || dst.H <= 0 || img.Bounds().Empty() { + return + } + sh := Shape{Kind: KindImage, Bounds: dst, Image: img} + if len(c.clips) > 0 { + cb, ok := bounds(c.clips[len(c.clips)-1].path) + if ok { + x0, y0 := math.Max(dst.X, cb.X), math.Max(dst.Y, cb.Y) + x1, y1 := math.Min(dst.X+dst.W, cb.X+cb.W), math.Min(dst.Y+dst.H, cb.Y+cb.H) + if x1 <= x0 || y1 <= y0 { + return + } + sh.Crop = [4]float64{(x0 - dst.X) / dst.W, (y0 - dst.Y) / dst.H, (dst.X + dst.W - x1) / dst.W, (dst.Y + dst.H - y1) / dst.H} + for i := range sh.Crop { + if sh.Crop[i] < 1e-6 { + sh.Crop[i] = 0 + } + } + sh.Bounds = paint.Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0} + } + } + c.out.Shapes = append(c.out.Shapes, sh) +} + +// Glyphs implements paint.Canvas: the run becomes text, joined to the line +// being built when it continues it — the same baseline, a word's space +// further — so that a line of words is one editable text box. +func (c *Canvas) Glyphs(run *paint.GlyphRun) { + if run == nil || len(run.Glyphs) == 0 || run.Size <= 0 || run.Font == nil { + return + } + face := c.face(run.Font) + var text []rune + for _, g := range run.Glyphs { + text = append(text, []rune(g.Text)...) + } + if len(text) == 0 { + return + } + upem := float64(run.Font.UnitsPerEm()) + last := run.Glyphs[len(run.Glyphs)-1] + end := run.X + last.X + float64(run.Font.GlyphAdvance(last.ID))*run.Size/upem + ascent := float64(run.Font.Ascent()) * run.Size / upem + descent := -float64(run.Font.Descent()) * run.Size / upem + if descent < 0 { + descent = -descent + } + // The letter-spacing is what the layout put between glyphs beyond their + // advances; one value for the run, rounded to a hundredth of a pixel so + // that runs of one style compare equal. + spacing := 0.0 + if n := len(run.Glyphs); n > 1 { + var extra float64 + for i := 0; i+1 < n; i++ { + g := run.Glyphs[i] + extra += run.Glyphs[i+1].X - g.X - float64(run.Font.GlyphAdvance(g.ID))*run.Size/upem + } + spacing = math.Round(extra/float64(n-1)*100) / 100 + if math.Abs(spacing) < 0.05 { + spacing = 0 + } + end += spacing + } + r := Run{Text: string(text), Face: face, Size: run.Size, Color: run.Color, Invisible: run.Invisible, Spacing: spacing, Link: c.linkAt(run.X, run.Y-ascent/2, end)} + if r.Invisible { + r.Color.A = 0 + } + top, h := run.Y-ascent, ascent+descent + + if n := len(c.out.Shapes); n > 0 { + prev := &c.out.Shapes[n-1] + if prev.Kind == KindText { + lastRun := &prev.Runs[len(prev.Runs)-1] + baseline := prev.Bounds.Y + prev.ascent() + gap := run.X - (prev.Bounds.X + prev.Bounds.W) + if math.Abs(baseline-run.Y) < 0.5 && gap > -1 && gap < 2*run.Size { + // The gap between two words is the space the layout left. + if gap > run.Size/20 { + lastRun.Text += " " + } + if lastRun.Face == r.Face && lastRun.Size == r.Size && lastRun.Color == r.Color && lastRun.Link == r.Link && lastRun.Invisible == r.Invisible && lastRun.Spacing == r.Spacing { + lastRun.Text += r.Text + } else { + prev.Runs = append(prev.Runs, r) + } + x0 := prev.Bounds.X + y0 := math.Min(prev.Bounds.Y, top) + y1 := math.Max(prev.Bounds.Y+prev.Bounds.H, top+h) + prev.Bounds = paint.Rect{X: x0, Y: y0, W: end - x0, H: y1 - y0} + return + } + } + } + c.out.Shapes = append(c.out.Shapes, Shape{Kind: KindText, Bounds: paint.Rect{X: run.X, Y: top, W: end - run.X, H: h}, Runs: []Run{r}}) +} + +// ascent is the distance from a text shape's top to its first run's +// baseline. +func (s *Shape) ascent() float64 { + r := s.Runs[0] + return float64(r.Face.Font.Ascent()) * r.Size / float64(r.Face.Font.UnitsPerEm()) +} + +// Baseline is a text shape's baseline, in device pixels from the page top. +func (s *Shape) Baseline() float64 { return s.Bounds.Y + s.ascent() } + +// linkAt is the link whose area holds the run's middle, or "". +func (c *Canvas) linkAt(x0, y, x1 float64) string { + p := image.Pt(int((x0+x1)/2), int(y)) + for _, l := range c.links { + if p.In(l.Rect) { + return l.Href + } + } + return "" +} + +func (c *Canvas) face(f *opentype.Font) *Face { + if fc, ok := c.faces[f]; ok { + return fc + } + family, sub := names(f) + lsub, lfam := lower(sub), lower(family) + // The style is the subfamily's word for it, or — for a font whose name + // table calls every face Regular — its OS/2 weight and slant, unless the + // family name already carries the weight ("Lato Black" is a family of + // its own, not a bold Lato). + weighted := false + for _, w := range []string{"thin", "light", "medium", "semibold", "bold", "black", "heavy"} { + if contains(lfam, w) { + weighted = true + } + } + fc := &Face{Font: f, Family: family, Embeddable: embeddable(f), + Bold: contains(lsub, "bold") || (!weighted && f.WeightClass() >= 600), + Italic: contains(lsub, "italic") || contains(lsub, "oblique") || (!contains(lfam, "italic") && f.IsItalic())} + if family == "" { + fc.Family = "sans-serif" + } + c.faces[f] = fc + return fc +} + +// bounds is a path's bounding box (control points included, which for the +// rounded rectangles and ellipses the painter makes is the shape's box). +func bounds(p *paint.Path) (paint.Rect, bool) { + if p == nil || len(p.Ops) == 0 { + return paint.Rect{}, false + } + x0, y0, x1, y1 := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1) + for _, op := range p.Ops { + n := 0 + switch op.Kind { + case paint.PathMoveTo, paint.PathLineTo: + n = 1 + case paint.PathCubeTo: + n = 3 + } + for _, pt := range op.P[:n] { + x0, y0 = math.Min(x0, pt.X), math.Min(y0, pt.Y) + x1, y1 = math.Max(x1, pt.X), math.Max(y1, pt.Y) + } + } + if x1 < x0 || y1 < y0 { + return paint.Rect{}, false + } + return paint.Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}, true +} + +// isRect reports whether p is exactly the rectangle b: one subpath of four +// axis-aligned lines. +func isRect(p *paint.Path, b paint.Rect) bool { + if len(p.Ops) != 5 || p.Ops[0].Kind != paint.PathMoveTo || p.Ops[4].Kind != paint.PathClose { + return false + } + for _, op := range p.Ops[:4] { + if op.Kind != paint.PathMoveTo && op.Kind != paint.PathLineTo { + return false + } + x, y := op.P[0].X, op.P[0].Y + if (x != b.X && x != b.X+b.W) || (y != b.Y && y != b.Y+b.H) { + return false + } + } + return true +} + +func covers(b, page paint.Rect) bool { + return b.X <= page.X+0.5 && b.Y <= page.Y+0.5 && b.X+b.W >= page.X+page.W-0.5 && b.Y+b.H >= page.Y+page.H-0.5 +} diff --git a/officecanvas/officecanvas_test.go b/officecanvas/officecanvas_test.go new file mode 100644 index 0000000..0614aaa --- /dev/null +++ b/officecanvas/officecanvas_test.go @@ -0,0 +1,186 @@ +// Copyright (c) the go-webengine/engine authors. +// SPDX-License-Identifier: BSD-3-Clause + +package officecanvas + +import ( + "bytes" + "context" + "encoding/base64" + "image" + "image/png" + "strings" + "testing" + + "github.com/go-opentype/fonts/inter" + "github.com/go-opentype/opentype" + + "github.com/go-webengine/engine" + "github.com/go-webengine/engine/css" + "github.com/go-webengine/engine/paint" +) + +func pngURI(t *testing.T, w, h int) string { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for i := range img.Pix { + img.Pix[i] = byte(i) + } + for i := 3; i < len(img.Pix); i += 4 { + img.Pix[i] = 255 + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatal(err) + } + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) +} + +// record lays html out at 400×300 and records it. +func record(t *testing.T, html string) (Page, map[*opentype.Font]*Face) { + t.Helper() + e := engine.New() + e.DisableJS = true + l, err := e.LayoutHTML(context.Background(), html, "https://example.com/", image.Rect(0, 0, 400, 300)) + if err != nil { + t.Fatal(err) + } + var links []Link + for _, ln := range l.Links() { + links = append(links, Link{Rect: ln.Rect, Href: ln.Href}) + } + faces := map[*opentype.Font]*Face{} + c := New(l.Page(), links, faces) + l.PaintVector(c) + return c.Page(), faces +} + +func TestAPageBecomesShapes(t *testing.T) { + html := ` +

Hello bold a link

+

spaced

+
+
+` + pg, faces := record(t, strings.Replace(html, "IMG", pngURI(t, 40, 10), 1)) + if pg.Background == nil || *pg.Background != (css.Color{R: 0x10, G: 0x20, B: 0x30, A: 255}) { + t.Fatalf("background = %v, want the body's colour", pg.Background) + } + var text []string + var grad *Shape + var img *Shape + for i := range pg.Shapes { + s := &pg.Shapes[i] + switch s.Kind { + case KindText: + var line []string + for _, r := range s.Runs { + line = append(line, r.Text) + } + text = append(text, strings.Join(line, "|")) + if strings.Contains(strings.Join(line, ""), "link") { + last := s.Runs[len(s.Runs)-1] + if last.Link != "https://example.com/x" { + t.Errorf("the link run links to %q", last.Link) + } + if !s.Runs[1].Face.Bold || s.Runs[0].Face.Bold { + t.Error("only the bold run is in a bold face") + } + } + if s.Runs[0].Text == "spaced" && !(s.Runs[0].Spacing > 2.5 && s.Runs[0].Spacing < 3.5) { + t.Errorf("letter spacing = %v, want 3", s.Runs[0].Spacing) + } + if b := s.Baseline(); b <= s.Bounds.Y || b >= s.Bounds.Y+s.Bounds.H { + t.Errorf("baseline %v is outside the box %+v", b, s.Bounds) + } + case KindGradient: + grad = s + case KindImage: + img = s + } + } + if got := strings.Join(text, " / "); got != "Hello |bold |a link / spaced" { + t.Errorf("lines = %q, want one box a line, runs split by style", got) + } + if grad == nil { + t.Fatal("no gradient shape") + } + if grad.Rect || grad.Gradient.Radial { + t.Error("a rounded linear gradient is not a plain rectangle, nor radial") + } + // Red to the middle, a hard edge, transparent after. + st := grad.Gradient.Stops + if len(st) < 4 || st[0].Color.A != 255 || st[len(st)-1].Color.A != 0 { + t.Errorf("stops = %+v, want opaque red then transparent", st) + } + if r := grad.Render(1); r == nil || r.NRGBAAt(1, 1).A != 0 || r.NRGBAAt(r.Bounds().Dx()/4, r.Bounds().Dy()/2).R < 200 { + t.Error("the gradient's picture should be clear in the rounded corner and red on the left") + } + if img == nil || img.Crop[0] <= 0 || img.Crop[2] <= 0 || img.Bounds.W != 100 { + t.Errorf("a wide picture covering its box should be cropped left and right: %+v", img) + } + for _, f := range faces { + if f.Family == "" || !f.Embeddable { + t.Errorf("face %+v: want a family, embeddable", f) + } + } +} + +func TestInvisibleTextIsTransparent(t *testing.T) { + pg, _ := record(t, `

ghost

`) + found := false + for _, s := range pg.Shapes { + if s.Kind == KindText && s.Runs[0].Text == "ghost" { + found = s.Runs[0].Invisible && s.Runs[0].Color.A == 0 + } + } + if !found { + t.Error("the text of a picture should be an invisible run with zero alpha") + } +} + +func TestSFNTIsTheFontAgain(t *testing.T) { + ot, err := opentype.Parse(inter.TTF) + if err != nil { + t.Fatal(err) + } + data := SFNT(ot) + back, err := opentype.Parse(data) + if err != nil { + t.Fatalf("the reassembled font does not parse: %v", err) + } + if back.NumGlyphs() != ot.NumGlyphs() || back.UnitsPerEm() != ot.UnitsPerEm() { + t.Error("the reassembled font is not the same font") + } + if fam, sub := names(back); fam != "Inter" || sub != "Regular" { + t.Errorf("names = %q %q", fam, sub) + } + if !embeddable(back) { + t.Error("Inter is OFL: embeddable") + } +} + +func TestEdgeCasesDrawNothing(t *testing.T) { + c := New(image.Rect(0, 0, 100, 100), nil, nil) + var empty paint.Path + c.Restore() // with nothing saved + c.Fill(&empty, css.Color{A: 255}, false) + c.Fill(nil, css.Color{A: 255}, false) + c.Shade(paint.Shading{Stops: []paint.GradientStop{{}, {Pos: 1}}}) // no clip + c.Image(nil, paint.Rect{W: 1, H: 1}) + c.Glyphs(nil) + c.Glyphs(&paint.GlyphRun{Size: 10}) + var sq paint.Path + sq.RoundRect(paint.Rect{X: 10, Y: 10, W: 10, H: 10}, 0) + c.Save() + c.Clip(&sq, false) + c.Image(image.NewRGBA(image.Rect(0, 0, 4, 4)), paint.Rect{X: 50, Y: 50, W: 10, H: 10}) // outside the clip + c.Restore() + if n := len(c.Page().Shapes); n != 0 || c.Page().Background != nil { + t.Errorf("%d shapes recorded from nothing", n) + } + s := Shape{Kind: KindFill} + if s.Render(1) != nil { + t.Error("only a gradient renders") + } +} diff --git a/officecanvas/render.go b/officecanvas/render.go new file mode 100644 index 0000000..69bca16 --- /dev/null +++ b/officecanvas/render.go @@ -0,0 +1,122 @@ +// Copyright (c) the go-webengine/engine authors. +// SPDX-License-Identifier: BSD-3-Clause + +package officecanvas + +import ( + "image" + "image/color" + "math" + + "golang.org/x/image/vector" + + "github.com/go-webengine/engine/paint" +) + +// maxRenderPixels bounds a gradient drawn as a picture (16 Mpx, 64 MiB). +const maxRenderPixels = 16 << 20 + +// Render draws a gradient shape as a picture of its Bounds, scale pixels per +// device pixel: the shadings it was made from, through its outline, +// antialiased, transparent outside. It is for a format whose gradients cannot +// say what this one says (more than two stops, varying opacity). It returns +// nil for another kind of shape, or one past 16 Mpx. +func (s *Shape) Render(scale float64) *image.NRGBA { + if s.Kind != KindGradient || s.Gradient == nil || !(scale > 0) { + return nil + } + w, h := int(math.Ceil(s.Bounds.W*scale)), int(math.Ceil(s.Bounds.H*scale)) + if w <= 0 || h <= 0 || int64(w)*int64(h) > maxRenderPixels { + return nil + } + // Coverage of the outline, from the path mapped to the picture. + z := vector.NewRasterizer(w, h) + mx := func(x float64) float32 { return float32((x - s.Bounds.X) * scale) } + my := func(y float64) float32 { return float32((y - s.Bounds.Y) * scale) } + for _, op := range s.Path.Ops { + switch op.Kind { + case paint.PathMoveTo: + z.MoveTo(mx(op.P[0].X), my(op.P[0].Y)) + case paint.PathLineTo: + z.LineTo(mx(op.P[0].X), my(op.P[0].Y)) + case paint.PathCubeTo: + z.CubeTo(mx(op.P[0].X), my(op.P[0].Y), mx(op.P[1].X), my(op.P[1].Y), mx(op.P[2].X), my(op.P[2].Y)) + case paint.PathClose: + z.ClosePath() + } + } + mask := image.NewAlpha(image.Rect(0, 0, w, h)) + z.Draw(mask, mask.Bounds(), image.Opaque, image.Point{}) + out := image.NewNRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + cov := mask.AlphaAt(x, y).A + if cov == 0 { + continue + } + px := s.Bounds.X + (float64(x)+0.5)/scale + py := s.Bounds.Y + (float64(y)+0.5)/scale + var r, g, b, a float64 // premultiplied, 0..1, composited over the shadings in order + for _, sh := range s.Gradient.Shadings { + c, ok := sample(sh, px, py) + if !ok { + continue + } + ca := float64(c.A) / 255 + r = float64(c.R)/255*ca + r*(1-ca) + g = float64(c.G)/255*ca + g*(1-ca) + b = float64(c.B)/255*ca + b*(1-ca) + a = ca + a*(1-ca) + } + if a <= 0 { + continue + } + a8 := a * float64(cov) / 255 + out.SetNRGBA(x, y, color.NRGBA{R: uint8(r/a*255 + 0.5), G: uint8(g/a*255 + 0.5), B: uint8(b/a*255 + 0.5), A: uint8(a8*255 + 0.5)}) + } + } + return out +} + +// sample is a shading's colour at (x,y), and whether it paints there. +func sample(s paint.Shading, x, y float64) (cssColor, bool) { + var t float64 + if s.Radial { + if s.RX <= 0 || s.RY <= 0 || s.R1 == s.R0 { + return cssColor{}, false + } + d := math.Hypot((x-s.CX)/s.RX, (y-s.CY)/s.RY) + t = (d - s.R0) / (s.R1 - s.R0) + } else { + ax, ay := s.X1-s.X0, s.Y1-s.Y0 + l2 := ax*ax + ay*ay + if l2 == 0 { + return cssColor{}, false + } + t = ((x-s.X0)*ax + (y-s.Y0)*ay) / l2 + } + if (t < 0 && !s.ExtendStart) || (t > 1 && !s.ExtendEnd) { + return cssColor{}, false + } + t = clamp01(t) + stops := s.Stops + if t <= stops[0].Pos { + c := stops[0].Color + return cssColor{c.R, c.G, c.B, c.A}, true + } + for i := 0; i+1 < len(stops); i++ { + a, b := stops[i], stops[i+1] + if t <= b.Pos { + f := 0.0 + if b.Pos > a.Pos { + f = (t - a.Pos) / (b.Pos - a.Pos) + } + l := func(u, v uint8) uint8 { return uint8(float64(u) + (float64(v)-float64(u))*f + 0.5) } + return cssColor{l(a.Color.R, b.Color.R), l(a.Color.G, b.Color.G), l(a.Color.B, b.Color.B), a.Color.A}, true + } + } + c := stops[len(stops)-1].Color + return cssColor{c.R, c.G, c.B, c.A}, true +} + +type cssColor struct{ R, G, B, A uint8 }