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 }