cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

HTML export: a sheet, a range or a chart as a web page in 012's look

Dami committed a173bc8
File > Download > Web page and 012 export out.html write one
self-contained page: IBM Plex Mono embedded, the reference palettes dark
or light, cells as the grid shows them with styles, borders, merges,
notes and rule looks, and charts as SVG drawn from the same layout as the
text chart and image. 012 export --chart writes one chart alone. HTML is
an export-only format (ExportKindOf); KindOf still names importable files.
commit
a173bc865019eeeb7300c1f78ba14cb8ba989d14
parent
27054a2ee38b35ce6f7c7a1f63103a2acef9f76c
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 1.6 of 3, Jev's estimate
34 files changed, +1782 -24
MNOTICE102++++++++++
MROADMAP.md2+-
Mcmd/012/export.go31+++-
Acmd/012/exporthtml_test.go72+++++++
Mdocs/files/README.md29+++-
Mdocs/files/scripts.md13+-
Mdocs/reference/command-line.md1+
Me2e/testdata/screens/menu-download.html4+-
Me2e/testdata/screens/palette-narrow.html2+-
Me2e/testdata/screens/palette-search-light.html2+-
Me2e/testdata/screens/palette-search.html2+-
Me2e/testdata/screens/palette-wide.html2+-
Me2e/testdata/screens/palette.html4+-
Minternal/chart/image.go23++
Minternal/chart/scatter.go4+
Ainternal/chart/svg.go311++++++++++++++++++++++++++++++
Ainternal/chart/svg_test.go71+++++++
Ainternal/fileio/fonts/OFL.txt93+++++++++
Ainternal/fileio/fonts/ibm-plex-mono-latin-400-italic.woff2bin
Ainternal/fileio/fonts/ibm-plex-mono-latin-400-normal.woff2bin
Ainternal/fileio/fonts/ibm-plex-mono-latin-600-normal.woff2bin
Minternal/fileio/formats.go20++-
Minternal/fileio/formats_test.go22++-
Ainternal/fileio/html.go125++++++++++++
Ainternal/fileio/html_test.go139+++++++++++++
Ainternal/fileio/htmlcell.go221+++++++++++++++++++++
Ainternal/fileio/htmlgrid.go239+++++++++++++++++++++++
Ainternal/fileio/htmlstyle.go169++++++++++++++++
Ainternal/fileio/snapdrawn.go32+++
Minternal/fileio/snapshot.go7+
Ainternal/headless/charts.go47+++++
Minternal/ui/cmdline.go2+-
Moracle/go.mod5+
Moracle/go.sum10+
M NOTICE +102 -0
@@ -28,3 +28,105 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
2828 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,2929 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE3030 SOFTWARE.31+32+-------------------------------------------------------------------------------33+internal/fileio/fonts/*.woff234+35+IBM Plex Mono (regular, semibold and italic, Latin subset), embedded in the36+web pages 012 writes, as packaged by Fontsource37+(https://fontsource.org/fonts/ibm-plex-mono, @fontsource/ibm-plex-mono). The38+license is also in internal/fileio/fonts/OFL.txt.39+40+Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ThinItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLight.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Light.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-LightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Regular.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Italic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Medium.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-MediumItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Bold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-BoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved.41+42+This Font Software is licensed under the SIL Open Font License, Version 1.1.43+This license is copied below, and is also available with a FAQ at:44+http://scripts.sil.org/OFL45+46+47+-----------------------------------------------------------48+SIL OPEN FONT LICENSE Version 1.1 - 26 February 200749+-----------------------------------------------------------50+51+PREAMBLE52+The goals of the Open Font License (OFL) are to stimulate worldwide53+development of collaborative font projects, to support the font creation54+efforts of academic and linguistic communities, and to provide a free and55+open framework in which fonts may be shared and improved in partnership56+with others.57+58+The OFL allows the licensed fonts to be used, studied, modified and59+redistributed freely as long as they are not sold by themselves. The60+fonts, including any derivative works, can be bundled, embedded,61+redistributed and/or sold with any software provided that any reserved62+names are not used by derivative works. The fonts and derivatives,63+however, cannot be released under any other type of license. The64+requirement for fonts to remain under this license does not apply65+to any document created using the fonts or their derivatives.66+67+DEFINITIONS68+"Font Software" refers to the set of files released by the Copyright69+Holder(s) under this license and clearly marked as such. This may70+include source files, build scripts and documentation.71+72+"Reserved Font Name" refers to any names specified as such after the73+copyright statement(s).74+75+"Original Version" refers to the collection of Font Software components as76+distributed by the Copyright Holder(s).77+78+"Modified Version" refers to any derivative made by adding to, deleting,79+or substituting -- in part or in whole -- any of the components of the80+Original Version, by changing formats or by porting the Font Software to a81+new environment.82+83+"Author" refers to any designer, engineer, programmer, technical84+writer or other person who contributed to the Font Software.85+86+PERMISSION & CONDITIONS87+Permission is hereby granted, free of charge, to any person obtaining88+a copy of the Font Software, to use, study, copy, merge, embed, modify,89+redistribute, and sell modified and unmodified copies of the Font90+Software, subject to the following conditions:91+92+1) Neither the Font Software nor any of its individual components,93+in Original or Modified Versions, may be sold by itself.94+95+2) Original or Modified Versions of the Font Software may be bundled,96+redistributed and/or sold with any software, provided that each copy97+contains the above copyright notice and this license. These can be98+included either as stand-alone text files, human-readable headers or99+in the appropriate machine-readable metadata fields within text or100+binary files as long as those fields can be easily viewed by the user.101+102+3) No Modified Version of the Font Software may use the Reserved Font103+Name(s) unless explicit written permission is granted by the corresponding104+Copyright Holder. This restriction only applies to the primary font name as105+presented to the users.106+107+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font108+Software shall not be used to promote, endorse or advertise any109+Modified Version, except to acknowledge the contribution(s) of the110+Copyright Holder(s) and the Author(s) or with their explicit written111+permission.112+113+5) The Font Software, modified or unmodified, in part or in whole,114+must be distributed entirely under this license, and must not be115+distributed under any other license. The requirement for fonts to116+remain under this license does not apply to any document created117+using the Font Software.118+119+TERMINATION120+This license becomes null and void if any of the above conditions are121+not met.122+123+DISCLAIMER124+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,125+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF126+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT127+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE128+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,129+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL130+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING131+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM132+OTHER DEALINGS IN THE FONT SOFTWARE.
M ROADMAP.md +1 -1
M cmd/012/export.go +28 -3
@@ -3,6 +3,7 @@ package main
33 import (44 "context"55 "fmt"6+ "io"67 "os"78 "path/filepath"89 "strings"
@@ -12,13 +13,13 @@ import (
1213 "github.com/FelineStateMachine/012/internal/sheet"1314 )1415 15-const exportUsage = "usage: 012 export file.012 out.csv|tsv|xlsx|json|nuon|sqlite [ref] [--format kind] [--table name] [--notebooks] [--jev] [--trust]"16+const exportUsage = "usage: 012 export file.012 out.csv|tsv|xlsx|json|nuon|sqlite|html [ref] [--format kind] [--table name] [--chart n|title] [--notebooks] [--jev] [--trust]"1617 1718 // runExport is 012 export: a sheet, a range or (to formats holding1819 // several sheets) the whole workbook written as File > Download writes1920 // it.2021 func runExport(args []string, e env) error {21- a, err := parseArgs(args, []string{"format", "table"}, append([]string{"help"}, evalFlags...))22+ a, err := parseArgs(args, []string{"format", "table", "chart"}, append([]string{"help"}, evalFlags...))2223 switch {2324 case err != nil:2425 return usageError(err.Error(), exportUsage)
@@ -33,6 +34,9 @@ func runExport(args []string, e env) error {
3334 if err != nil {3435 return usageError(err.Error(), exportUsage)3536 }37+ if a.has("chart") && k != fileio.HTML {38+ return usageError("--chart writes a web page: name an .html file", exportUsage)39+ }3640 if same(src, out) {3741 return fmt.Errorf("%s is the workbook being exported: name another file", out)3842 }
@@ -52,6 +56,9 @@ func runExport(args []string, e env) error {
5256 if err := failed.fatal(); err != nil {5357 return err5458 }59+ if a.has("chart") {60+ return exportChart(out, t, a.flags["chart"], failed)61+ }5562 snap := exportSnap(t, k, ref == "")5663 res, err := fileio.Export(context.Background(), out, k, snap, fileio.ExportOptions{Table: a.flags["table"]})5764 if err != nil {
@@ -65,7 +72,7 @@ func runExport(args []string, e env) error {
6572 6673 // exportKind is the format to write: --format's, or out's extension's.6774 func exportKind(out, named string) (fileio.Kind, error) {68- k, ok := fileio.KindOf(out)75+ k, ok := fileio.ExportKindOf(out)6976 if named != "" {7077 k, ok = fileio.KindNamed(named)7178 if !ok {
@@ -115,3 +122,21 @@ func same(a, b string) bool {
115122 sb, err2 := os.Stat(b)116123 return err1 == nil && err2 == nil && os.SameFile(sa, sb)117124 }125+126+// exportChart is 012 export --chart: one chart of the target's sheet as127+// a web page of its own, found by its number (1 is the first) or title.128+func exportChart(out string, t headless.Target, which string, failed evalFailures) error {129+ i, err := headless.FindChart(t.Sheet, which)130+ if err != nil {131+ return err132+ }133+ c, _ := fileio.ChartSnap(t.Sheet, i)134+ err = headless.WriteAtomic(out, 0o644, func(w io.Writer) error {135+ _, err := fileio.WriteHTML(w, fileio.HTMLPage{Title: headless.ChartTitle(c.Chart), Chart: &c})136+ return err137+ })138+ if err != nil {139+ return fmt.Errorf("%s: %w", out, err)140+ }141+ return failed.err()142+}
A cmd/012/exporthtml_test.go +72 -0
@@ -0,0 +1,72 @@
1+package main2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// chartFile writes a workbook of two columns and a chart titled Sales.13+func chartFile(t *testing.T, dir string) string {14+ t.Helper()15+ w := sheet.NewBook()16+ s := w.Sheet(0)17+ for i, in := range []string{"Month", "Jan", "Feb"} {18+ s.Set(sheet.Addr{Row: i}, in)19+ }20+ for i, in := range []string{"Sales", "10", "20"} {21+ s.Set(sheet.Addr{Col: 1, Row: i}, in)22+ }23+ s.AddChart(sheet.Chart{Type: sheet.ChartLine, Data: sheet.Rect{To: sheet.Addr{Col: 1, Row: 2}}, Header: true, Labels: true,24+ Title: "Sales", At: sheet.Addr{Col: 3}, W: 30, H: 10})25+ path := filepath.Join(dir, "book.012")26+ f, err := os.Create(path)27+ if err != nil {28+ t.Fatal(err)29+ }30+ defer f.Close()31+ if err := w.Write(f); err != nil {32+ t.Fatal(err)33+ }34+ return path35+}36+37+func TestExportHTML(t *testing.T) {38+ e, _, _ := scriptEnv(t)39+ dir := t.TempDir()40+ path := chartFile(t, dir)41+ out := filepath.Join(dir, "book.html")42+ if code, err := status(e, "export", path, out); code != 0 {43+ t.Fatal(err)44+ }45+ data, _ := os.ReadFile(out)46+ if page := string(data); !strings.Contains(page, `data-a="B3" class="r">20<`) || !strings.Contains(page, "<polyline") {47+ t.Errorf("page lacks the cells or the chart")48+ }49+ chart := filepath.Join(dir, "chart.html")50+ for _, which := range []string{"1", "sales"} {51+ os.Remove(chart)52+ if code, err := status(e, "export", path, chart, "--chart", which); code != 0 {53+ t.Fatal(err)54+ }55+ data, _ := os.ReadFile(chart)56+ if page := string(data); !strings.Contains(page, "<title>Sales</title>") || strings.Contains(page, "<table") {57+ t.Errorf("--chart %s: not the chart alone", which)58+ }59+ }60+ for _, c := range []struct {61+ args []string62+ want string63+ }{64+ {[]string{path, chart, "--chart", "2"}, "Sheet1 has 1 chart: chart 2 isn't one"},65+ {[]string{path, chart, "--chart", "Costs"}, `no chart titled "Costs" (charts: Sales)`},66+ {[]string{path, filepath.Join(dir, "x.csv"), "--chart", "1"}, "--chart writes a web page"},67+ } {68+ if _, err := status(e, append([]string{"export"}, c.args...)...); err == nil || !strings.Contains(err.Error(), c.want) {69+ t.Errorf("export %v: %v, want %q", c.args, err, c.want)70+ }71+ }72+}
M docs/files/README.md +27 -2
@@ -27,9 +27,10 @@ it back in its format.
2727 | Lotus 1-2-3 `.wk1`, `.wks` | Numbers, labels with their alignment, formats, column widths, formulas translated (references, operators, `@SUM`, `@AVG`, `@IF`, `@ROUND`, `@PMT` and 60 more) or kept as values | |2828 | JSON `.json`, `.ndjson`, `.jsonl` | A list of records (or records one after another, as NDJSON): keys become the header row, numbers, booleans and text keep their types, nested lists and records are their text | The sheet shown as a list of records named by its first row, numbers as numbers |2929 | Nushell `.nuon` | A nushell table with its types: file sizes, durations and dates become numbers in the Size, Duration and Date time formats; see [Nushell types](../nushell/types.md) | The sheet shown as a nushell table, first row as column names, types kept by the cells' formats |30+| Web page `.html` | | The sheet shown as a page in 012's look, for someone without 012: see [Web pages](#web-pages) |3031 31-CSV, TSV, JSON and NUON downloads hold the rows a filter shows, as32-Sheets copies a filtered range; XLSX keeps every row, the hidden ones32+CSV, TSV, JSON, NUON and HTML downloads hold the rows a filter shows,33+as Sheets copies a filtered range; XLSX keeps every row, the hidden ones3334 hidden.3435 3536 Formats other than XLSX import as one sheet named after the file (or the
@@ -66,6 +67,30 @@ the context line says how many rows were left out, e.g. `only the first
6667 Data past the grid's edges is left out the same way. WK1 files keep their6768 own 8,192 by 256. See [Bounds of support](../contributing/limits.md#imports) for speeds.6869 70+## Web pages71+72+A web page download is one `.html` file that needs nothing else: its73+fonts (IBM Plex Mono), styles, charts and a few lines of script are in74+it, so it opens offline and can be mailed or put on any web server. It75+draws the sheet as 012 does: column letters and row numbers, each cell's76+text as the grid shows it at its column's width, with its bold, italic,77+alignment, wrapping, borders, merges, row heights, notes (the `▝` mark,78+the note as its tooltip), tables' header rows and bands, and what79+conditional formats and validation show (fills, color scales, data80+bars, icons, checkboxes, dropdown marks). Charts float over the cells81+at their corner, in their frames, drawn as SVG: shapes rather than82+pixels, sharp at any zoom. The colors are the 16 ANSI colors of 012's83+reference palettes, dark or light as the reader's system prefers.84+85+Clicking a cell, or moving with the arrow keys, puts the pointer on it:86+the name box shows its address and the formula bar what was typed in87+it, a formula rather than its value. A page draws at most 200,00088+cells, blank ones included, and says on standard error or the context89+line how many rows it left out.90+91+`012 export` also writes a range as a page, or one chart alone with92+`--chart` ([Scripts](scripts.md#export)).93+6994 ## More7095 7196 | Page | For |
M docs/files/scripts.md +11 -2
M docs/reference/command-line.md +1 -0
M e2e/testdata/screens/menu-download.html +2 -2
@@ -12,8 +12,8 @@
1212 <span style="color:var(--vt-palette-8)">├─────────────────────┤│</span> SQLite database (.sqlite) <span style="color:var(--vt-palette-8)">│</span>1313 <span style="color:var(--vt-palette-8)">│</span> Quit <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Q</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">││</span> JSON list of records (.json) <span style="color:var(--vt-palette-8)">│</span>1414 <span style="color:var(--vt-palette-8)">└─────────────────────┘│</span> Nushell table (.nuon) <span style="color:var(--vt-palette-8)">│</span>15-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└──────────────────────────────────┘</span>16-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Web page (.html) <span style="color:var(--vt-palette-8)">│</span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└──────────────────────────────────┘</span>1717 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>
M e2e/testdata/screens/palette-narrow.html +1 -1
@@ -12,5 +12,5 @@
1212 <span style="color:var(--vt-palette-8)">│</span> Sort <span style="color:var(--vt-palette-3);font-weight:bold">s</span>heet Z to <span style="color:var(--vt-palette-3);font-weight:bold">A</span> <span style="color:var(--vt-palette-8)">│</span>1313 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Ask</span> <span style="color:var(--vt-palette-8)">JEV</span> <span style="color:var(--vt-palette-8)">again</span> <span style="color:var(--vt-palette-8)">│</span>1414 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>ort r<span style="color:var(--vt-palette-3);font-weight:bold">a</span>nge <span style="color:var(--vt-palette-8)">│</span>15-<span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">64</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">225</span> <span style="color:var(--vt-palette-8)">─┘</span>15+<span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">65</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">226</span> <span style="color:var(--vt-palette-8)">─┘</span>1616 Save the sheet u… <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">move</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">run</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file
M e2e/testdata/screens/palette-search-light.html +1 -1
@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Reset <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umn width <span style="color:var(--vt-palette-0)">Format</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Insert <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns right <span style="color:var(--vt-palette-0)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Delete rows or <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">Edit</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Alt+-</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">48</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">225</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">48</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">226</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>1919 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>2020 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>
M e2e/testdata/screens/palette-search.html +1 -1
@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Reset <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umn width <span style="color:var(--vt-palette-8)">Format</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Insert <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns right <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Delete rows or <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">Edit</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Alt+-</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">48</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">225</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">48</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">226</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>
M e2e/testdata/screens/palette-wide.html +1 -1
@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>av<span style="color:var(--vt-palette-3);font-weight:bold">e</span> as <span style="color:var(--vt-palette-8)">Fi</span><span style="color:var(--vt-palette-3);font-weight:bold">l</span><span style="color:var(--vt-palette-8)">e</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Code</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Shell</span> <span style="color:var(--vt-palette-8)">y</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> In<span style="color:var(--vt-palette-3);font-weight:bold">se</span>rt rows be<span style="color:var(--vt-palette-3);font-weight:bold">l</span>ow <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">59</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">225</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">60</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">226</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>
M e2e/testdata/screens/palette.html +2 -2
@@ -13,8 +13,8 @@
1313 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Download as SQLite <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Download as JSON <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Download as NUON <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>16-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Decimal arithmetic <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Settings</span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">225</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">225</span> <span style="color:var(--vt-palette-8)">─┘</span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Download as HTML <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">226</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">226</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>
M internal/chart/image.go +23 -0
@@ -46,6 +46,9 @@ type canvas struct {
4646 *image.RGBA4747 cw, ch float644848 pal Palette49+ // vec, when set, takes the shapes as SVG instead of pixels, and50+ // RGBA has no pixels, only its bounds (see SVG).51+ vec *vector4952 }5053 5154 // blend paints c over the pixel at x, y with coverage a.
@@ -65,6 +68,10 @@ func (cv *canvas) blend(x, y int, c color.RGBA, a float64) {
6568 6669 // rect fills x0..x1, y0..y1 in pixels, antialiasing fractional edges.6770 func (cv *canvas) rect(x0, y0, x1, y1 float64, c color.RGBA) {71+ if cv.vec != nil {72+ cv.vec.rect(x0, y0, x1, y1, c)73+ return74+ }6875 for y := int(math.Floor(y0)); float64(y) < y1; y++ {6976 cy := min(y1, float64(y+1)) - max(y0, float64(y))7077 for x := int(math.Floor(x0)); float64(x) < x1; x++ {
@@ -76,6 +83,10 @@ func (cv *canvas) rect(x0, y0, x1, y1 float64, c color.RGBA) {
7683 7784 // hline draws a one pixel horizontal line at y.7885 func (cv *canvas) hline(y int, c color.RGBA) {86+ if cv.vec != nil {87+ cv.vec.line(float64(cv.Rect.Min.X), float64(y)+0.5, float64(cv.Rect.Max.X), float64(y)+0.5, c)88+ return89+ }7990 for x := cv.Rect.Min.X; x < cv.Rect.Max.X; x++ {8091 cv.blend(x, y, c, 1)8192 }
@@ -83,6 +94,10 @@ func (cv *canvas) hline(y int, c color.RGBA) {
8394 8495 // vline draws a one pixel vertical line at x.8596 func (cv *canvas) vline(x int, c color.RGBA) {97+ if cv.vec != nil {98+ cv.vec.line(float64(x)+0.5, float64(cv.Rect.Min.Y), float64(x)+0.5, float64(cv.Rect.Max.Y), c)99+ return100+ }86101 for y := cv.Rect.Min.Y; y < cv.Rect.Max.Y; y++ {87102 cv.blend(x, y, c, 1)88103 }
@@ -91,6 +106,10 @@ func (cv *canvas) vline(x int, c color.RGBA) {
91106 // polyline strokes the points with a round-capped line width px wide.92107 // Coverage is kept per pixel, so joints aren't painted twice.93108 func (cv *canvas) polyline(pts [][2]float64, width float64, c color.RGBA) {109+ if cv.vec != nil {110+ cv.vec.polyline(pts, width, c)111+ return112+ }94113 b := cv.Rect95114 cover := make([]float64, b.Dx()*b.Dy())96115 r := width / 2
@@ -213,6 +232,10 @@ func (p *barPlan) image(cv *canvas) {
213232 214233 // image draws the pie, supersampled, with a thin gap between slices.215234 func (p *piePlan) image(cv *canvas) {235+ if cv.vec != nil {236+ p.vector(cv)237+ return238+ }216239 b := cv.Rect217240 cx, cy := float64(b.Dx())/2, float64(b.Dy())/2218241 pr := newPieRaster(p, cx, cy, min(cx, cy)-1, 1)
M internal/chart/scatter.go +4 -0
A internal/chart/svg.go +311 -0
@@ -0,0 +1,311 @@
1+package chart2+3+import (4+ "fmt"5+ "html"6+ "image"7+ "image/color"8+ "math"9+ "strings"10+11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// SVG charts are the text chart and the image in one vector drawing,15+// for the HTML export: the axes, labels and legend Draw lays out, as16+// text on a grid of SVGCellW by SVGCellH pixels, and the plot area as17+// the shapes Image paints, as SVG shapes rather than pixels. Colors are18+// classes the page styles, so a chart follows the page's light or dark19+// palette:20+//21+// ct-ax, ct-lb, ct-mu text of the axes, the labels and messages22+// ct-s0 .. ct-s5 text in a series' color23+// cf-0 .. cf-5 shapes filled in a series' color24+// cs-0 .. cs-5 lines stroked in a series' color25+// cs-gl gridlines26+// cs-box axes, in their text class's color27+// cs-gap the gaps between a pie's slices28+const (29+ SVGCellW = 930+ SVGCellH = 2131+)32+33+// vecGrid and vecSeries stand for the colors in the palette a vector34+// canvas paints with, so each shape finds its class again: the red35+// channel is the series and the blue one says it's a gridline.36+var vecGrid = color.RGBA{B: 1, A: 255}37+38+func vecSeries(i int) color.RGBA { return color.RGBA{R: uint8(i), A: 255} }39+40+// SVG draws d as a chart of type t in w by h cells, as an svg element41+// of w*SVGCellW by h*SVGCellH pixels whose title is title.42+func SVG(t sheet.ChartType, d sheet.ChartData, w, h int, o Options, title string) string {43+ w, h = max(w, 0), max(h, 0)44+ o.Image, o.CellW, o.CellH = true, SVGCellW, SVGCellH45+ g := Draw(t, d, w, h, o)46+ var b strings.Builder47+ fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" class="chart" width="%d" height="%d" viewBox="0 0 %d %d" role="img">`,48+ w*SVGCellW, h*SVGCellH, w*SVGCellW, h*SVGCellH)49+ if title != "" {50+ b.WriteString("<title>" + html.EscapeString(title) + "</title>")51+ }52+ if !g.Plot.Empty() {53+ b.WriteString(plotSVG(t, d, w, h, o, g.Plot))54+ }55+ gridText(&b, g)56+ b.WriteString("</svg>")57+ return b.String()58+}59+60+// plotSVG is the plot area's shapes, placed over its cells.61+func plotSVG(t sheet.ChartType, d sheet.ChartData, w, h int, o Options, plot image.Rectangle) string {62+ p, _ := planFor(t, d, w, h, o)63+ pal := Palette{Grid: vecGrid}64+ for i := range pal.Series {65+ pal.Series[i] = vecSeries(i)66+ }67+ v := &vector{}68+ cv := &canvas{69+ RGBA: &image.RGBA{Rect: image.Rect(0, 0, plot.Dx()*SVGCellW, plot.Dy()*SVGCellH)},70+ cw: SVGCellW, ch: SVGCellH, pal: pal, vec: v,71+ }72+ p.image(cv)73+ v.flush()74+ return fmt.Sprintf(`<g transform="translate(%d %d)">%s</g>`, plot.Min.X*SVGCellW, plot.Min.Y*SVGCellH, v.b.String())75+}76+77+// gridText writes the text chart's cells, a run of one role at a time.78+func gridText(b *strings.Builder, g *Grid) {79+ for y := range g.H {80+ x := 081+ for x < g.W {82+ c := g.At(x, y)83+ if c.Text == " " || c.Text == "" || c.Fg == None {84+ x++85+ continue86+ }87+ if arms, ok := boxArms[c.Text]; ok {88+ boxLines(b, x, y, arms, textClass(c.Fg))89+ x++90+ continue91+ }92+ start, role := x, c.Fg93+ var run strings.Builder94+ for x < g.W && g.At(x, y).Fg == role && !isBox(g.At(x, y).Text) {95+ run.WriteString(g.At(x, y).Text)96+ x++97+ }98+ fmt.Fprintf(b, `<text x="%d" y="%.1f" class="%s" xml:space="preserve">%s</text>`, start*SVGCellW,99+ float64(y)*SVGCellH+SVGCellH*0.72, textClass(role), html.EscapeString(strings.TrimRight(run.String(), " ")))100+ }101+ }102+}103+104+// boxArms are the box-drawing characters the axes use, as the arms105+// each draws from the middle of its cell: up, down, left, right. They106+// are drawn as lines, which meet whatever font the page has.107+var boxArms = map[string][4]bool{108+ "│": {true, true, false, false}, "─": {false, false, true, true},109+ "└": {true, false, false, true}, "┘": {true, false, true, false},110+ "┌": {false, true, false, true}, "┐": {false, true, true, false},111+ "├": {true, true, false, true}, "┤": {true, true, true, false},112+ "┬": {false, true, true, true}, "┴": {true, false, true, true},113+ "┼": {true, true, true, true},114+}115+116+func isBox(s string) bool { _, ok := boxArms[s]; return ok }117+118+// boxLines draws a box-drawing character's arms in cell x, y.119+func boxLines(b *strings.Builder, x, y int, arms [4]bool, class string) {120+ cx, cy := float64(x)*SVGCellW+SVGCellW/2, float64(y)*SVGCellH+SVGCellH/2121+ ends := [4][2]float64{{cx, float64(y) * SVGCellH}, {cx, float64(y+1) * SVGCellH},122+ {float64(x) * SVGCellW, cy}, {float64(x+1) * SVGCellW, cy}}123+ for i, on := range arms {124+ if on {125+ fmt.Fprintf(b, `<line x1="%s" y1="%s" x2="%s" y2="%s" class="%s cs-box"/>`, num(cx), num(cy), num(ends[i][0]), num(ends[i][1]), class)126+ }127+ }128+}129+130+func textClass(r Role) string {131+ switch {132+ case r == Axis, r == Gridline:133+ return "ct-ax"134+ case r == Label:135+ return "ct-lb"136+ case r >= Series:137+ return fmt.Sprintf("ct-s%d", int(r-Series)%Colors)138+ }139+ return "ct-mu"140+}141+142+// vector collects a canvas's shapes as SVG. Area charts paint a pixel143+// column at a time; those columns are joined into one polygon per color144+// for as long as they run on without a gap.145+type vector struct {146+ b strings.Builder147+ strips map[color.RGBA]*strip148+ order []color.RGBA149+}150+151+// strip is a run of one-pixel columns of one color: their tops and152+// bottoms, left to right.153+type strip struct {154+ x0, x1 float64155+ top, below [][2]float64156+}157+158+func paint(c color.RGBA) (series int, grid bool, opacity string) {159+ if c.A < 255 {160+ opacity = fmt.Sprintf(` fill-opacity="%.2f"`, float64(c.A)/255)161+ }162+ return int(c.R) % Colors, c.B == 1, opacity163+}164+165+func (v *vector) rect(x0, y0, x1, y1 float64, c color.RGBA) {166+ if x1 <= x0 || y1 <= y0 {167+ return168+ }169+ if x1-x0 <= 1.001 {170+ v.column(x0, y0, x1, y1, c)171+ return172+ }173+ v.flush()174+ s, _, op := paint(c)175+ fmt.Fprintf(&v.b, `<rect x="%s" y="%s" width="%s" height="%s" class="cf-%d"%s/>`, num(x0), num(y0), num(x1-x0), num(y1-y0), s, op)176+}177+178+// column adds a pixel column to its color's strip, starting a new strip179+// when it doesn't continue the last one.180+func (v *vector) column(x0, y0, x1, y1 float64, c color.RGBA) {181+ if v.strips == nil {182+ v.strips = map[color.RGBA]*strip{}183+ }184+ st := v.strips[c]185+ if st != nil && math.Abs(st.x1-x0) > 0.001 {186+ v.emitStrip(c, st)187+ st = nil188+ }189+ if st == nil {190+ st = &strip{x0: x0}191+ v.strips[c] = st192+ v.order = append(v.order, c)193+ }194+ st.x1 = x1195+ st.top = append(st.top, [2]float64{x0, y0}, [2]float64{x1, y0})196+ st.below = append(st.below, [2]float64{x0, y1}, [2]float64{x1, y1})197+}198+199+// flush writes the strips still open, in the order they started, so200+// they're under whatever is drawn next.201+func (v *vector) flush() {202+ for _, c := range v.order {203+ if st := v.strips[c]; st != nil {204+ v.emitStrip(c, st)205+ }206+ }207+ v.order = v.order[:0]208+}209+210+func (v *vector) emitStrip(c color.RGBA, st *strip) {211+ delete(v.strips, c)212+ pts := simplify(st.top)213+ below := simplify(st.below)214+ for i := len(below) - 1; i >= 0; i-- {215+ pts = append(pts, below[i])216+ }217+ s, _, op := paint(c)218+ fmt.Fprintf(&v.b, `<polygon points="%s" class="cf-%d"%s/>`, points(pts), s, op)219+}220+221+func (v *vector) line(x0, y0, x1, y1 float64, c color.RGBA) {222+ v.flush()223+ class := "cs-gl"224+ if s, grid, _ := paint(c); !grid {225+ class = fmt.Sprintf("cs-%d", s)226+ }227+ fmt.Fprintf(&v.b, `<line x1="%s" y1="%s" x2="%s" y2="%s" class="%s"/>`, num(x0), num(y0), num(x1), num(y1), class)228+}229+230+func (v *vector) polyline(pts [][2]float64, width float64, c color.RGBA) {231+ v.flush()232+ s, _, _ := paint(c)233+ if len(pts) == 1 {234+ fmt.Fprintf(&v.b, `<circle cx="%s" cy="%s" r="%s" class="cf-%d"/>`, num(pts[0][0]), num(pts[0][1]), num(width/2), s)235+ return236+ }237+ fmt.Fprintf(&v.b, `<polyline points="%s" fill="none" stroke-width="%s" stroke-linecap="round" stroke-linejoin="round" class="cs-%d"/>`,238+ points(pts), num(width), s)239+}240+241+func (v *vector) disc(cx, cy, r float64, c color.RGBA) {242+ v.flush()243+ s, _, _ := paint(c)244+ fmt.Fprintf(&v.b, `<circle cx="%s" cy="%s" r="%s" class="cf-%d"/>`, num(cx), num(cy), num(r), s)245+}246+247+// vector draws the pie as one path a slice, with the page's background248+// stroked between them as the image leaves a gap.249+func (p *piePlan) vector(cv *canvas) {250+ v := cv.vec251+ b := cv.Rect252+ cx, cy := float64(b.Dx())/2, float64(b.Dy())/2253+ r := min(cx, cy) - 1254+ if len(p.slices) == 1 {255+ fmt.Fprintf(&v.b, `<circle cx="%s" cy="%s" r="%s" class="cf-0"/>`, num(cx), num(cy), num(r))256+ return257+ }258+ at := func(f float64) string {259+ a := f * 2 * math.Pi260+ return num(cx+r*math.Sin(a)) + " " + num(cy-r*math.Cos(a))261+ }262+ for i, s := range p.slices {263+ if s.to <= s.from {264+ continue265+ }266+ large := 0267+ if s.to-s.from > 0.5 {268+ large = 1269+ }270+ fmt.Fprintf(&v.b, `<path d="M%s %s L%s A%s %s 0 %d 1 %s Z" class="cf-%d cs-gap"/>`,271+ num(cx), num(cy), at(s.from), num(r), num(r), large, at(s.to), i%Colors)272+ }273+}274+275+// simplify drops the points of a path that lie on the line between276+// their neighbors.277+func simplify(pts [][2]float64) [][2]float64 {278+ if len(pts) < 3 {279+ return pts280+ }281+ out := [][2]float64{pts[0]}282+ for i := 1; i < len(pts)-1; i++ {283+ a, b, c := out[len(out)-1], pts[i], pts[i+1]284+ if math.Abs((b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0])) > 0.25 {285+ out = append(out, b)286+ }287+ }288+ return append(out, pts[len(pts)-1])289+}290+291+func points(pts [][2]float64) string {292+ var b strings.Builder293+ for i, p := range pts {294+ if i > 0 {295+ b.WriteByte(' ')296+ }297+ b.WriteString(num(p[0]) + "," + num(p[1]))298+ }299+ return b.String()300+}301+302+// num writes a coordinate to a tenth of a pixel, without trailing303+// zeros.304+func num(f float64) string {305+ s := fmt.Sprintf("%.1f", f)306+ s = strings.TrimSuffix(s, ".0")307+ if s == "-0" {308+ return "0"309+ }310+ return s311+}
A internal/chart/svg_test.go +71 -0
@@ -0,0 +1,71 @@
1+package chart2+3+import (4+ "encoding/xml"5+ "io"6+ "strings"7+ "testing"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// TestSVGEveryType draws each type as SVG, checking it's well-formed13+// XML with the chart's labels as text and shapes of its own kind.14+func TestSVGEveryType(t *testing.T) {15+ cases := []struct {16+ typ sheet.ChartType17+ data sheet.ChartData18+ opts sheet.ChartOptions19+ shape string20+ }{21+ {sheet.ChartColumn, budget, sheet.ChartOptions{}, "<rect"},22+ {sheet.ChartBar, single, sheet.ChartOptions{}, "<rect"},23+ {sheet.ChartLine, budget, sheet.ChartOptions{}, "<polyline"},24+ {sheet.ChartArea, budget, sheet.ChartOptions{}, "<polygon"},25+ {sheet.ChartArea, budget, sheet.ChartOptions{Stack: sheet.StackNormal}, "<polygon"},26+ {sheet.ChartPie, single, sheet.ChartOptions{}, "<path"},27+ {sheet.ChartScatter, xy, sheet.ChartOptions{Trend: true}, "<circle"},28+ {sheet.ChartColumn, signs, sheet.ChartOptions{Stack: sheet.StackNormal}, "<rect"},29+ }30+ for _, c := range cases {31+ t.Run(c.typ.String(), func(t *testing.T) {32+ svg := SVG(c.typ, c.data, 60, 18, Options{Chart: c.opts}, "Budget & more")33+ d := xml.NewDecoder(strings.NewReader(svg))34+ for {35+ if _, err := d.Token(); err == io.EOF {36+ break37+ } else if err != nil {38+ t.Fatalf("not XML: %v\n%s", err, svg)39+ }40+ }41+ if !strings.Contains(svg, c.shape) {42+ t.Errorf("no %s in\n%s", c.shape, svg)43+ }44+ if !strings.Contains(svg, "<title>Budget &amp; more</title>") {45+ t.Errorf("no title")46+ }47+ if c.typ != sheet.ChartPie && !strings.Contains(svg, `class="ct-lb"`) {48+ t.Errorf("no labels")49+ }50+ })51+ }52+}53+54+// TestSVGAreaIsFewShapes joins an area's pixel columns into polygons.55+func TestSVGAreaIsFewShapes(t *testing.T) {56+ svg := SVG(sheet.ChartArea, single, 80, 20, Options{}, "")57+ if n := strings.Count(svg, "<polygon"); n == 0 || n > 3 {58+ t.Errorf("%d polygons", n)59+ }60+ if strings.Contains(svg, "<rect") {61+ t.Errorf("area columns drawn as rects")62+ }63+}64+65+// TestSVGNothingToPlot shows the text chart's message alone.66+func TestSVGNothingToPlot(t *testing.T) {67+ svg := SVG(sheet.ChartColumn, sheet.ChartData{}, 40, 10, Options{}, "")68+ if !strings.Contains(svg, "No numbers to chart") || strings.Contains(svg, "<g ") {69+ t.Errorf("got %s", svg)70+ }71+}
A internal/fileio/fonts/OFL.txt +93 -0
@@ -0,0 +1,93 @@
1+Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ThinItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLight.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Light.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-LightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Regular.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Italic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Medium.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-MediumItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Bold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-BoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved.2+3+This Font Software is licensed under the SIL Open Font License, Version 1.1.4+This license is copied below, and is also available with a FAQ at:5+http://scripts.sil.org/OFL6+7+8+-----------------------------------------------------------9+SIL OPEN FONT LICENSE Version 1.1 - 26 February 200710+-----------------------------------------------------------11+12+PREAMBLE13+The goals of the Open Font License (OFL) are to stimulate worldwide14+development of collaborative font projects, to support the font creation15+efforts of academic and linguistic communities, and to provide a free and16+open framework in which fonts may be shared and improved in partnership17+with others.18+19+The OFL allows the licensed fonts to be used, studied, modified and20+redistributed freely as long as they are not sold by themselves. The21+fonts, including any derivative works, can be bundled, embedded,22+redistributed and/or sold with any software provided that any reserved23+names are not used by derivative works. The fonts and derivatives,24+however, cannot be released under any other type of license. The25+requirement for fonts to remain under this license does not apply26+to any document created using the fonts or their derivatives.27+28+DEFINITIONS29+"Font Software" refers to the set of files released by the Copyright30+Holder(s) under this license and clearly marked as such. This may31+include source files, build scripts and documentation.32+33+"Reserved Font Name" refers to any names specified as such after the34+copyright statement(s).35+36+"Original Version" refers to the collection of Font Software components as37+distributed by the Copyright Holder(s).38+39+"Modified Version" refers to any derivative made by adding to, deleting,40+or substituting -- in part or in whole -- any of the components of the41+Original Version, by changing formats or by porting the Font Software to a42+new environment.43+44+"Author" refers to any designer, engineer, programmer, technical45+writer or other person who contributed to the Font Software.46+47+PERMISSION & CONDITIONS48+Permission is hereby granted, free of charge, to any person obtaining49+a copy of the Font Software, to use, study, copy, merge, embed, modify,50+redistribute, and sell modified and unmodified copies of the Font51+Software, subject to the following conditions:52+53+1) Neither the Font Software nor any of its individual components,54+in Original or Modified Versions, may be sold by itself.55+56+2) Original or Modified Versions of the Font Software may be bundled,57+redistributed and/or sold with any software, provided that each copy58+contains the above copyright notice and this license. These can be59+included either as stand-alone text files, human-readable headers or60+in the appropriate machine-readable metadata fields within text or61+binary files as long as those fields can be easily viewed by the user.62+63+3) No Modified Version of the Font Software may use the Reserved Font64+Name(s) unless explicit written permission is granted by the corresponding65+Copyright Holder. This restriction only applies to the primary font name as66+presented to the users.67+68+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font69+Software shall not be used to promote, endorse or advertise any70+Modified Version, except to acknowledge the contribution(s) of the71+Copyright Holder(s) and the Author(s) or with their explicit written72+permission.73+74+5) The Font Software, modified or unmodified, in part or in whole,75+must be distributed entirely under this license, and must not be76+distributed under any other license. The requirement for fonts to77+remain under this license does not apply to any document created78+using the Font Software.79+80+TERMINATION81+This license becomes null and void if any of the above conditions are82+not met.83+84+DISCLAIMER85+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,86+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF87+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT88+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE89+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,90+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL91+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING92+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM93+OTHER DEALINGS IN THE FONT SOFTWARE.
A internal/fileio/fonts/ibm-plex-mono-latin-400-italic.woff2 +0 -0

Binary file.

A internal/fileio/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0

Binary file.

A internal/fileio/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0

Binary file.

M internal/fileio/formats.go +19 -1
@@ -19,6 +19,7 @@ const (
1919 WK12020 JSON2121 NUON22+ HTML2223 )2324 2425 // A fileFormat is everything about a Kind: its names, its extensions,
@@ -95,6 +96,10 @@ var kinds = []fileFormat{{
9596 kind: NUON, name: "NUON", noun: "NUON", label: "Nushell table (NUON)", exts: []string{".nuon"},9697 read: importNUON, write: exportNUON, menu: "Nushell table", encode: encodeNUON,9798 about: "Save the sheet as a nushell table that keeps its types, named by its first row",99+}, {100+ kind: HTML, name: "HTML", noun: "HTML", label: "Web page", exts: []string{".html", ".htm"},101+ write: exportHTML, menu: "Web page",102+ about: "Save the sheet as a web page in 012's look, with its charts, to share with anyone",98103 }}99104 100105 // unknownFormat is what an invalid Kind reports.
@@ -117,8 +122,18 @@ func Kinds() []Kind {
117122 return out118123 }119124 120-// KindOf recognizes a file's format by its extension.125+// KindOf recognizes a file 012 imports by its extension.121126 func KindOf(name string) (Kind, bool) {127+ k, ok := ExportKindOf(name)128+ if !ok || !k.CanImport() {129+ return 0, false130+ }131+ return k, true132+}133+134+// ExportKindOf recognizes a file's format by its extension, including135+// formats 012 only writes (HTML).136+func ExportKindOf(name string) (Kind, bool) {122137 ext := strings.ToLower(filepath.Ext(name))123138 for _, k := range kinds {124139 for _, e := range k.exts {
@@ -130,6 +145,9 @@ func KindOf(name string) (Kind, bool) {
130145 return 0, false131146 }132147 148+// CanImport reports whether 012 reads files of this kind.149+func (k Kind) CanImport() bool { return k.format().read != nil }150+133151 // String is the format's short name, e.g. "XLSX".134152 func (k Kind) String() string { return k.format().name }135153
M internal/fileio/formats_test.go +16 -6
A internal/fileio/html.go +125 -0
@@ -0,0 +1,125 @@
1+package fileio2+3+import (4+ "context"5+ "fmt"6+ "html"7+ "io"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// HTML exports are static pages in 012's look (see htmlstyle.go): the13+// control panel's lines above the grid, the grid with its headers and14+// charts, and a small script that puts the pointer on a cell clicked or15+// moved to with the arrow keys, showing its address in the name box and16+// what it holds in the formula bar, as the screen does. The page needs17+// nothing beyond itself: fonts, styles, SVG charts and script are in18+// the file.19+20+// HTMLPage is what an HTML page shows: a sheet or range, or one chart.21+type HTMLPage struct {22+ Title string // the page's title, and the first line's23+ Snap *Snapshot // the cells, when Chart is nil24+ Chart *SnapChart25+}26+27+// WriteHTML writes p as a page. The note, when there is one, says what28+// the page left out.29+func WriteHTML(w io.Writer, p HTMLPage) (note string, err error) {30+ body, name, where := "", "", ""31+ switch {32+ case p.Chart != nil:33+ body, name, where = HTMLChart(*p.Chart), p.Chart.Data.String(), p.Chart.Type.Title()+" chart of "+p.Chart.Data.String()34+ case p.Snap != nil:35+ body, note = HTMLGrid(p.Snap)36+ where = p.Snap.Name + "!" + p.Snap.Range.String()37+ }38+ _, err = io.WriteString(w, HTMLDocument(p.Title, HTMLPanel(p.Title, name, where)+body, ""))39+ return note, err40+}41+42+// HTMLDocument is a whole page: title, style sheet, body and script,43+// with extra added to the script (the MCP view's messages).44+func HTMLDocument(title, body, extra string) string {45+ return "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\">" +46+ "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">" +47+ "<meta name=\"generator\" content=\"012\"><title>" + html.EscapeString(title) + "</title>\n<style>\n" +48+ HTMLStyle() + "</style></head>\n<body><div class=\"o12\">" + body +49+ "</div>\n<script>\n" + htmlScript + extra + "</script></body></html>\n"50+}51+52+// HTMLPanel is the control panel's three lines: the title with the53+// mode indicator, the name box (holding name until a cell is pointed54+// at) and formula bar, and the context line saying what the page55+// shows.56+func HTMLPanel(title, name, where string) string {57+ return fmt.Sprintf(`<header class="panel"><div><span class="book">%s</span><span class="mode">READY</span></div>`+58+ `<div><span class="namebox" id="o12-name">%s</span><span class="input" id="o12-input"></span></div>`+59+ `<div class="context" id="o12-context">%s</div></header>`, html.EscapeString(title), html.EscapeString(name), html.EscapeString(where))60+}61+62+// exportHTML writes a sheet or range as a page.63+func exportHTML(_ context.Context, name string, snap *Snapshot, _ ExportOptions) (*ExportResult, error) {64+ var note string65+ err := writeFile(name, func(w io.Writer) error {66+ var err error67+ note, err = WriteHTML(w, HTMLPage{Title: snap.Name, Snap: snap})68+ return err69+ })70+ if err != nil {71+ return nil, err72+ }73+ res := &ExportResult{Rows: snap.Range.To.Row - snap.Range.From.Row + 1}74+ if len(snap.Cells) == 0 {75+ res.Rows = 076+ }77+ if note != "" {78+ res.Notes = append(res.Notes, note)79+ }80+ return res, nil81+}82+83+// ChartSnap is chart i of s with the values it draws, for a page of84+// its own.85+func ChartSnap(s *sheet.Sheet, i int) (SnapChart, bool) {86+ charts := s.Charts()87+ if i < 0 || i >= len(charts) {88+ return SnapChart{}, false89+ }90+ return SnapChart{Chart: charts[i], Values: s.ChartData(charts[i])}, true91+}92+93+// htmlScript moves the pointer: a click or an arrow key puts it on a94+// cell, whose address goes in the name box, what was typed in it in95+// the formula bar, and whose row and column headers light up. It works96+// on grids added to the page later, as the MCP view adds them.97+const htmlScript = `(function(){98+var cur=null;99+function col(s){var n=0;for(var i=0;i<s.length;i++)n=n*26+s.charCodeAt(i)-64;return n}100+function name(n){var s="";while(n>0){n--;s=String.fromCharCode(65+n%26)+s;n=Math.floor(n/26)}return s}101+function split(a){var m=/^([A-Z]+)(\d+)$/.exec(a);return m?[col(m[1]),+m[2]]:null}102+function point(td){103+ if(cur){cur.classList.remove("ptr")}104+ document.querySelectorAll("th.on").forEach(function(t){t.classList.remove("on")});105+ cur=td;if(!td)return;106+ td.classList.add("ptr");107+ var a=td.getAttribute("data-a"),p=split(a),g=td.closest("table");108+ var nb=document.getElementById("o12-name"),inp=document.getElementById("o12-input");109+ if(nb)nb.textContent=a;110+ if(inp)inp.textContent=td.hasAttribute("data-f")?td.getAttribute("data-f"):td.textContent;111+ if(p&&g){var c=g.querySelector('th[data-c="'+name(p[0])+'"]'),r=g.querySelector('th[data-r="'+p[1]+'"]');112+ if(c)c.classList.add("on");if(r)r.classList.add("on")}113+}114+document.addEventListener("click",function(e){var td=e.target.closest&&e.target.closest("td[data-a]");if(td)point(td)});115+document.addEventListener("keydown",function(e){116+ if(!cur)return;var d={ArrowLeft:[-1,0],ArrowRight:[1,0],ArrowUp:[0,-1],ArrowDown:[0,1]}[e.key];if(!d)return;117+ var p=split(cur.getAttribute("data-a")),g=cur.closest("table");if(!p||!g)return;118+ for(var k=1;k<64;k++){var n=g.querySelector('td[data-a="'+name(p[0]+d[0]*k)+(p[1]+d[1]*k)+'"]');119+ if(n){point(n);n.scrollIntoView({block:"nearest",inline:"nearest"});e.preventDefault();return}120+ if(p[0]+d[0]*k<1||p[1]+d[1]*k<1)return}121+});122+window.o12point=point;123+var first=document.querySelector("td[data-a]");if(first)point(first);124+})();125+`
A internal/fileio/html_test.go +139 -0
@@ -0,0 +1,139 @@
1+package fileio2+3+import (4+ "bytes"5+ "context"6+ "os"7+ "path/filepath"8+ "regexp"9+ "strings"10+ "testing"11+12+ "github.com/FelineStateMachine/012/internal/sheet"13+)14+15+// htmlBook is a sheet with a header, numbers, a formula, a style, a16+// merge, a note and a chart.17+func htmlBook(t *testing.T) *sheet.Sheet {18+ t.Helper()19+ s := sheet.New()20+ for a, in := range map[string]string{21+ "A1": "Month", "B1": "Sales", "A2": "Jan", "B2": "$1,200", "A3": "Feb", "B3": "900",22+ "A4": "Total", "B4": "=SUM(B2:B3)", "A6": "<b>not markup</b>",23+ } {24+ addr, _ := sheet.ParseAddr(a)25+ if err := s.Set(addr, in); err != nil {26+ t.Fatal(err)27+ }28+ }29+ s.SetStyle(sheet.Rect{To: sheet.Addr{Col: 1}}, func(st *sheet.Style) { st.Bold = true })30+ if err := s.SetNote(sheet.Addr{Row: 3}, "adds up"); err != nil {31+ t.Fatal(err)32+ }33+ if err := s.Merge(sheet.Rect{From: sheet.Addr{Row: 5}, To: sheet.Addr{Col: 1, Row: 5}}, sheet.MergeAll); err != nil {34+ t.Fatal(err)35+ }36+ s.AddChart(sheet.Chart{Type: sheet.ChartColumn, Data: sheet.Rect{To: sheet.Addr{Col: 1, Row: 2}}, Header: true, Labels: true,37+ Title: "Sales by month", At: sheet.Addr{Col: 3, Row: 1}, W: 40, H: 14})38+ return s39+}40+41+// TestHTMLPage exports a sheet as a page that holds everything it42+// needs: fonts, styles, the cells as shown, the chart as SVG.43+func TestHTMLPage(t *testing.T) {44+ s := htmlBook(t)45+ path := filepath.Join(t.TempDir(), "sales.html")46+ k, ok := ExportKindOf(path)47+ if !ok || k != HTML || k.CanImport() {48+ t.Fatalf("ExportKindOf = %v, %v", k, ok)49+ }50+ if _, ok := KindOf(path); ok {51+ t.Errorf("an HTML file is offered for import")52+ }53+ res, err := Export(context.Background(), path, HTML, Snap(s, sheet.Rect{}, "Sales"), ExportOptions{})54+ if err != nil {55+ t.Fatal(err)56+ }57+ if res.Rows != 6 || len(res.Notes) != 0 {58+ t.Errorf("rows %d, notes %q", res.Rows, res.Notes)59+ }60+ data, err := os.ReadFile(path)61+ if err != nil {62+ t.Fatal(err)63+ }64+ page := string(data)65+ for _, want := range []string{66+ "<title>Sales</title>", "@font-face{font-family:'IBM Plex Mono'", "data:font/woff2;base64,",67+ `<th data-c="A">A</th>`, `data-a="B1" class="b">Sales<`, `data-a="A2" class="clip">Jan<`, `data-a="B2" class="r">$1,200<`,68+ `data-f="=SUM(B2:B3)">$2,100<`, `class="clip note" title="adds up"`, `colspan="2" rowspan="1"`, "&lt;b&gt;not markup&lt;/b&gt;",69+ `<figure class="chart"`, "<svg", "<figcaption>Sales by month</figcaption>", `class="range">A1:B3<`,70+ "prefers-color-scheme:light", "READY",71+ } {72+ if !strings.Contains(page, want) {73+ t.Errorf("page lacks %q", want)74+ }75+ }76+ // Nothing is fetched from elsewhere.77+ if m := regexp.MustCompile(`(src|href)="?https?:`).FindString(page); m != "" {78+ t.Errorf("page loads %s", m)79+ }80+}81+82+// TestHTMLRange exports a range, with the charts anchored in it.83+func TestHTMLRange(t *testing.T) {84+ s := htmlBook(t)85+ var b bytes.Buffer86+ snap := Snap(s, sheet.Rect{From: sheet.Addr{Row: 1}, To: sheet.Addr{Col: 1, Row: 2}}, "Sales")87+ if _, err := WriteHTML(&b, HTMLPage{Title: "Sales", Snap: snap}); err != nil {88+ t.Fatal(err)89+ }90+ page := b.String()91+ if strings.Contains(page, `data-a="A1"`) || !strings.Contains(page, `data-a="B3"`) || strings.Contains(page, "<svg") {92+ t.Errorf("range page wrong:\n%s", page[strings.Index(page, "<body>"):])93+ }94+ if !strings.Contains(page, `id="o12-context">Sales!A2:B3<`) {95+ t.Errorf("context line doesn't name the range")96+ }97+}98+99+// TestHTMLChartPage draws one chart on its own page.100+func TestHTMLChartPage(t *testing.T) {101+ s := htmlBook(t)102+ c, ok := ChartSnap(s, 0)103+ if !ok {104+ t.Fatal("no chart")105+ }106+ if _, ok := ChartSnap(s, 1); ok {107+ t.Error("chart 1 found")108+ }109+ var b bytes.Buffer110+ if _, err := WriteHTML(&b, HTMLPage{Title: "Sales by month", Chart: &c}); err != nil {111+ t.Fatal(err)112+ }113+ page := b.String()114+ if !strings.Contains(page, `<figure class="chart solo"`) || !strings.Contains(page, "<rect") || strings.Contains(page, "<table") {115+ t.Errorf("chart page wrong")116+ }117+}118+119+// TestHTMLLooks draws a rule's fill and text color.120+func TestHTMLLooks(t *testing.T) {121+ s := sheet.New()122+ for i, in := range []string{"5", "50"} {123+ if err := s.Set(sheet.Addr{Row: i}, in); err != nil {124+ t.Fatal(err)125+ }126+ }127+ err := s.AddCondFormat(sheet.CondFormat{Ranges: []sheet.Rect{{To: sheet.Addr{Row: 1}}}, Op: sheet.RuleGreater, Args: [2]string{"10"},128+ Style: sheet.RuleStyle{Fill: sheet.ColorGreen, Bold: true}})129+ if err != nil {130+ t.Fatal(err)131+ }132+ grid, _ := HTMLGrid(Snap(s, sheet.Rect{}, "S"))133+ if !strings.Contains(grid, `data-a="A2" class="r b" style="background:var(--r3);color:var(--i3)"`) {134+ t.Errorf("rule not drawn:\n%s", grid)135+ }136+ if strings.Contains(grid, `data-a="A1" class="r b"`) {137+ t.Errorf("rule drawn where it doesn't apply")138+ }139+}
A internal/fileio/htmlcell.go +221 -0
@@ -0,0 +1,221 @@
1+package fileio2+3+import (4+ "fmt"5+ "html"6+ "strings"7+8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+11+// htmlCell is what a td says about its cell: classes, inline styles,12+// attributes and the text shown.13+type htmlCell struct {14+ class []string15+ style []string16+ attrs string17+ text string18+ icon string // an icon set's glyph, before the text19+}20+21+// writeCell writes the td of the cell at a: spanning a merge, with the22+// text as the grid shows it at the cell's width.23+func (l *htmlLayout) writeCell(b *strings.Builder, a sheet.Addr) {24+ snap := l.snap25+ var h htmlCell26+ width := htmlColWidth(snap, a.Col)27+ if m, ok := l.merged[a]; ok {28+ width = 029+ for col := m.From.Col; col <= m.To.Col; col++ {30+ width += htmlColWidth(snap, col)31+ }32+ h.attrs += fmt.Sprintf(` colspan="%d" rowspan="%d"`, m.To.Col-m.From.Col+1, l.visibleRows(m))33+ }34+ c, filled := snap.Cells[a]35+ if filled {36+ h.content(snap, c, width)37+ h.textStyle(c.Style)38+ h.borders(c.Style.Borders)39+ if c.Style.Wrap == sheet.WrapOverflow && h.text != "" && l.nextFilled(a) {40+ h.class = append(h.class, "clip")41+ }42+ }43+ if look, ok := snap.Looks[a]; ok {44+ h.look(look)45+ }46+ if role := l.tableRole[a]; role != "" {47+ h.class = append(h.class, role)48+ }49+ if note, ok := snap.Notes[a]; ok {50+ h.class = append(h.class, "note")51+ h.attrs += ` title="` + html.EscapeString(note) + `"`52+ }53+ fmt.Fprintf(b, `<td data-a="%s"`, a)54+ if len(h.class) > 0 {55+ b.WriteString(` class="` + strings.Join(h.class, " ") + `"`)56+ }57+ if len(h.style) > 0 {58+ b.WriteString(` style="` + html.EscapeString(strings.Join(h.style, ";")) + `"`)59+ }60+ b.WriteString(h.attrs + ">")61+ if h.icon != "" {62+ b.WriteString(h.icon)63+ }64+ b.WriteString(html.EscapeString(h.text) + "</td>")65+}66+67+// visibleRows is how many drawn rows a merge spans.68+func (l *htmlLayout) visibleRows(m sheet.Rect) int {69+ n := 070+ for row := m.From.Row; row <= m.To.Row; row++ {71+ if !l.snap.HiddenRows[row] {72+ n++73+ }74+ }75+ return max(n, 1)76+}77+78+// nextFilled reports whether the cell right of a holds something, which79+// a's text stops at rather than running on, as in Sheets.80+func (l *htmlLayout) nextFilled(a sheet.Addr) bool {81+ next := sheet.Addr{Col: a.Col + 1, Row: a.Row}82+ c, ok := l.snap.Cells[next]83+ return ok && (c.Input != "" || c.Value.Kind != sheet.Empty) || l.covered[next]84+}85+86+// content is the cell's text as the grid shows it in width columns,87+// where it goes, and what was typed when that isn't the text.88+func (h *htmlCell) content(snap *Snapshot, c SnapCell, width int) {89+ text, align := sheet.DisplayIn(c.Value, c.Format, width, snap.Locale)90+ h.text = text91+ switch c.Style.Align {92+ case sheet.AlignLeft:93+ align = sheet.AlignLeft94+ case sheet.AlignCenter:95+ align = sheet.AlignCenter96+ case sheet.AlignRight:97+ align = sheet.AlignRight98+ }99+ switch align {100+ case sheet.AlignRight:101+ h.class = append(h.class, "r")102+ case sheet.AlignCenter:103+ h.class = append(h.class, "c")104+ }105+ if c.Value.Kind == sheet.Error {106+ h.class = append(h.class, "err")107+ }108+ if c.Input != "" && c.Input != text {109+ h.attrs += ` data-f="` + html.EscapeString(c.Input) + `"`110+ }111+ switch c.Style.Wrap {112+ case sheet.WrapOn:113+ h.class = append(h.class, "wrap")114+ case sheet.WrapClip:115+ h.class = append(h.class, "clip")116+ }117+ switch c.Style.VAlign {118+ case sheet.VAlignTop:119+ h.class = append(h.class, "t")120+ case sheet.VAlignMiddle:121+ h.class = append(h.class, "m")122+ }123+}124+125+// textStyle adds bold, italic, underline and strikethrough.126+func (h *htmlCell) textStyle(st sheet.Style) {127+ for _, f := range []struct {128+ on bool129+ class string130+ }{{st.Bold, "b"}, {st.Italic, "i"}, {st.Underline, "u"}, {st.Strikethrough, "s"}} {131+ if f.on {132+ h.class = append(h.class, f.class)133+ }134+ }135+}136+137+// borders draws each edge's line in its color, or the border role's138+// when it has none.139+func (h *htmlCell) borders(b sheet.Borders) {140+ if b.IsZero() {141+ return142+ }143+ for _, e := range []struct {144+ edge sheet.Edge145+ side string146+ }{{sheet.EdgeTop, "top"}, {sheet.EdgeBottom, "bottom"}, {sheet.EdgeLeft, "left"}, {sheet.EdgeRight, "right"}} {147+ st := b.Stroke(e.edge)148+ line := ""149+ switch st.Line {150+ case sheet.LineThin:151+ line = "1px solid"152+ case sheet.LineThick:153+ line = "2px solid"154+ case sheet.LineDouble:155+ line = "3px double"156+ default:157+ continue158+ }159+ color := "var(--border)"160+ if st.Color != sheet.ColorNone {161+ color = ruleVar(st.Color)162+ }163+ h.style = append(h.style, "border-"+e.side+":"+line+" "+color)164+ }165+}166+167+func ruleVar(c sheet.Color) string { return fmt.Sprintf("var(--r%d)", int(c)) }168+func inkVar(c sheet.Color) string { return fmt.Sprintf("var(--i%d)", int(c)) }169+170+// look draws what the sheet's rules show: a conditional format's171+// colors and text style, a color scale, a data bar, an icon, a172+// checkbox, a dropdown's marker and an invalid entry's underline.173+func (h *htmlCell) look(l sheet.Look) {174+ if l.Styled {175+ h.ruleStyle(l.Style)176+ }177+ if l.Scaled {178+ pct := int(l.Pos*100 + 0.5)179+ h.style = append(h.style, fmt.Sprintf("background:color-mix(in srgb,%s %d%%,%s)", ruleVar(l.To), pct, ruleVar(l.From)))180+ ink := l.From181+ if l.Pos >= 0.5 {182+ ink = l.To183+ }184+ h.style = append(h.style, "color:"+inkVar(ink))185+ }186+ if l.Bar {187+ pct := int(l.BarLen*100 + 0.5)188+ h.style = append(h.style, fmt.Sprintf("background:linear-gradient(to right,color-mix(in srgb,%s 55%%,transparent) %d%%,transparent %d%%)",189+ ruleVar(l.BarColor), pct, pct))190+ }191+ if l.ValueHidden {192+ h.text = ""193+ }194+ if l.Icon != "" {195+ h.icon = fmt.Sprintf(`<span class="icon" style="color:%s">%s</span>`, ruleVar(l.IconColor), html.EscapeString(l.Icon))196+ }197+ switch {198+ case l.Checkbox && l.Checked:199+ h.text, h.class = "[✓]", append(h.class, "c")200+ case l.Checkbox:201+ h.text, h.class = "[ ]", append(h.class, "c")202+ case l.Dropdown:203+ h.text += " ▾"204+ }205+ if l.Invalid {206+ h.class = append(h.class, "bad")207+ }208+}209+210+// ruleStyle is a single-color rule's fill, text color and text style.211+func (h *htmlCell) ruleStyle(st sheet.RuleStyle) {212+ switch {213+ case st.Fill != sheet.ColorNone && (st.Text == sheet.ColorNone || st.Text == st.Fill):214+ h.style = append(h.style, "background:"+ruleVar(st.Fill), "color:"+inkVar(st.Fill))215+ case st.Fill != sheet.ColorNone:216+ h.style = append(h.style, "background:"+ruleVar(st.Fill), "color:"+ruleVar(st.Text))217+ case st.Text != sheet.ColorNone:218+ h.style = append(h.style, "color:"+ruleVar(st.Text))219+ }220+ h.textStyle(sheet.Style{Bold: st.Bold, Italic: st.Italic, Underline: st.Underline, Strikethrough: st.Strikethrough})221+}
A internal/fileio/htmlgrid.go +239 -0
@@ -0,0 +1,239 @@
1+package fileio2+3+import (4+ "fmt"5+ "html"6+ "sort"7+ "strconv"8+ "strings"9+10+ "github.com/FelineStateMachine/012/internal/chart"11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// maxHTMLCells bounds the cells a page draws, blank ones included: a15+// browser shows a table of about this many at once without trouble.16+// Past it the page draws the first rows and says so.17+const maxHTMLCells = 200_00018+19+// htmlLayout is where the grid's rows and columns go: the columns and20+// rows drawn, in order, and the pixel offset of each from the grid's21+// top-left corner, headers included.22+type htmlLayout struct {23+ snap *Snapshot24+ cols, rows []int25+ x, y map[int]int // left edge of a column, top edge of a row26+ headW int // the row numbers' width27+ cut int // rows left out past maxHTMLCells28+ merged map[sheet.Addr]sheet.Rect29+ covered map[sheet.Addr]bool30+ tableRole map[sheet.Addr]string31+}32+33+// layout places the snapshot's range, grown to hold its charts.34+func layout(snap *Snapshot) *htmlLayout {35+ r := snap.Range36+ colsTo, rowsTo := r.To.Col, r.To.Row37+ for _, c := range snap.Charts {38+ colsTo = max(colsTo, chartEndCol(snap, c))39+ rowsTo = max(rowsTo, chartEndRow(snap, c))40+ }41+ l := &htmlLayout{snap: snap, x: map[int]int{}, y: map[int]int{}}42+ for col := r.From.Col; col <= min(colsTo, sheet.MaxCols-1); col++ {43+ l.cols = append(l.cols, col)44+ }45+ maxRows := max(maxHTMLCells/max(len(l.cols), 1), 100)46+ for row := r.From.Row; row <= min(rowsTo, sheet.MaxRows-1); row++ {47+ if snap.HiddenRows[row] {48+ continue49+ }50+ if len(l.rows) == maxRows {51+ l.cut = rowsTo - row + 152+ break53+ }54+ l.rows = append(l.rows, row)55+ }56+ last := r.From.Row + 157+ if len(l.rows) > 0 {58+ last = l.rows[len(l.rows)-1] + 159+ }60+ l.headW = (max(len(strconv.Itoa(last)), 3) + 2) * htmlCellW61+ x := l.headW62+ for _, col := range l.cols {63+ l.x[col] = x64+ x += htmlColWidth(snap, col) * htmlCellW65+ }66+ y := htmlCellH67+ for _, row := range l.rows {68+ l.y[row] = y69+ y += htmlRowHeight(snap, row) * htmlCellH70+ }71+ l.mergesAndTables()72+ return l73+}74+75+func htmlColWidth(snap *Snapshot, col int) int {76+ if w, ok := snap.Widths[col]; ok {77+ return w78+ }79+ return sheet.DefaultWidth80+}81+82+func htmlRowHeight(snap *Snapshot, row int) int {83+ if h, ok := snap.Heights[row]; ok && h > 0 {84+ return h85+ }86+ return 187+}88+89+// chartEndCol is the last column a chart covers.90+func chartEndCol(snap *Snapshot, c SnapChart) int {91+ col, w := c.At.Col, c.W92+ for w > htmlColWidth(snap, col) && col < sheet.MaxCols-1 {93+ w -= htmlColWidth(snap, col)94+ col++95+ }96+ return col97+}98+99+// chartEndRow is the last row a chart covers.100+func chartEndRow(snap *Snapshot, c SnapChart) int {101+ row, h := c.At.Row, c.H102+ for h > htmlRowHeight(snap, row) && row < sheet.MaxRows-1 {103+ h -= htmlRowHeight(snap, row)104+ row++105+ }106+ return row107+}108+109+// mergesAndTables notes the merged cells and the cells tables style.110+func (l *htmlLayout) mergesAndTables() {111+ l.merged, l.covered, l.tableRole = map[sheet.Addr]sheet.Rect{}, map[sheet.Addr]bool{}, map[sheet.Addr]string{}112+ for _, m := range l.snap.Merges {113+ l.merged[m.From] = m114+ for row := m.From.Row; row <= m.To.Row; row++ {115+ for col := m.From.Col; col <= m.To.Col; col++ {116+ if a := (sheet.Addr{Col: col, Row: row}); a != m.From {117+ l.covered[a] = true118+ }119+ }120+ }121+ }122+ for _, t := range l.snap.Tables {123+ for col := t.Range.From.Col; col <= t.Range.To.Col; col++ {124+ if t.Header {125+ l.tableRole[sheet.Addr{Col: col, Row: t.Range.From.Row}] = "th"126+ }127+ for row := t.Range.From.Row + 2; t.Banded && row <= t.Range.To.Row; row += 2 {128+ l.tableRole[sheet.Addr{Col: col, Row: row}] = "band"129+ }130+ }131+ }132+}133+134+// HTMLGrid draws the snapshot as 012's grid: column letters and row135+// numbers, the cells as they show with their styles, borders and the136+// looks of rules, and the charts over them. It returns the fragment and137+// a note when rows were left out.138+func HTMLGrid(snap *Snapshot) (string, string) {139+ l := layout(snap)140+ var b strings.Builder141+ b.WriteString(`<div class="sheet"><table class="grid"><colgroup>`)142+ fmt.Fprintf(&b, `<col style="width:%dpx">`, l.headW)143+ for _, col := range l.cols {144+ fmt.Fprintf(&b, `<col style="width:%dpx">`, htmlColWidth(snap, col)*htmlCellW)145+ }146+ b.WriteString(`</colgroup><thead><tr><th class="corner"></th>`)147+ for _, col := range l.cols {148+ fmt.Fprintf(&b, `<th data-c="%s">%s</th>`, sheet.ColName(col), sheet.ColName(col))149+ }150+ b.WriteString("</tr></thead><tbody>")151+ for _, row := range l.rows {152+ l.writeRow(&b, row)153+ }154+ b.WriteString("</tbody></table>")155+ for _, c := range snap.Charts {156+ l.writeChart(&b, c)157+ }158+ b.WriteString("</div>")159+ note := ""160+ if l.cut > 0 {161+ note = fmt.Sprintf("HTML pages show the first %d rows: %d more are left out", len(l.rows), l.cut)162+ }163+ return b.String(), note164+}165+166+func (l *htmlLayout) writeRow(b *strings.Builder, row int) {167+ style := ""168+ if h := htmlRowHeight(l.snap, row); h > 1 {169+ style = fmt.Sprintf(` style="height:%dpx"`, h*htmlCellH)170+ }171+ fmt.Fprintf(b, `<tr%s><th data-r="%d">%d</th>`, style, row+1, row+1)172+ for _, col := range l.cols {173+ a := sheet.Addr{Col: col, Row: row}174+ if l.covered[a] {175+ continue176+ }177+ l.writeCell(b, a)178+ }179+ b.WriteString("</tr>")180+}181+182+// writeChart draws a chart in its frame over the cells under it, as183+// the screen does: the title in the top border, the range in the184+// bottom one.185+func (l *htmlLayout) writeChart(b *strings.Builder, c SnapChart) {186+ x, y := l.at(c.At)187+ fmt.Fprintf(b, `<figure class="chart" style="left:%dpx;top:%dpx;width:%dpx;height:%dpx">`,188+ x, y, c.W*htmlCellW, c.H*htmlCellH)189+ writeChartBody(b, c)190+ b.WriteString("</figure>")191+}192+193+// writeChartBody is a chart's frame labels and drawing, inside its194+// figure.195+func writeChartBody(b *strings.Builder, c SnapChart) {196+ fmt.Fprintf(b, `<figcaption>%s</figcaption>`, html.EscapeString(chartTitle(c.Chart)))197+ b.WriteString(chart.SVG(c.Type, c.Values, c.W-4, c.H-2, chart.Options{Chart: c.ChartOptions}, chartTitle(c.Chart)))198+ fmt.Fprintf(b, `<span class="range">%s</span>`, c.Data.String())199+}200+201+// chartTitle is a chart's title, or its type's name when it has none.202+func chartTitle(c sheet.Chart) string {203+ if c.Title != "" {204+ return c.Title205+ }206+ return c.Type.Title() + " chart"207+}208+209+// at is the pixel offset of a cell's top-left corner; a cell in a210+// hidden row or past the drawn ones sits where the next drawn one does.211+func (l *htmlLayout) at(a sheet.Addr) (int, int) {212+ x, ok := l.x[a.Col]213+ if !ok {214+ x = l.headW215+ }216+ y, ok := l.y[a.Row]217+ if !ok {218+ i := sort.SearchInts(l.rows, a.Row)219+ switch {220+ case i < len(l.rows):221+ y = l.y[l.rows[i]]222+ case len(l.rows) > 0:223+ last := l.rows[len(l.rows)-1]224+ y = l.y[last] + htmlRowHeight(l.snap, last)*htmlCellH225+ default:226+ y = htmlCellH227+ }228+ }229+ return x, y230+}231+232+// HTMLChart draws one chart alone, in its frame.233+func HTMLChart(c SnapChart) string {234+ var b strings.Builder235+ fmt.Fprintf(&b, `<figure class="chart solo" style="width:%dpx;height:%dpx">`, c.W*htmlCellW, c.H*htmlCellH)236+ writeChartBody(&b, c)237+ b.WriteString("</figure>")238+ return b.String()239+}
A internal/fileio/htmlstyle.go +169 -0
@@ -0,0 +1,169 @@
1+package fileio2+3+import (4+ "embed"5+ "encoding/base64"6+ "fmt"7+ "math"8+ "strings"9+ "sync"10+11+ "github.com/FelineStateMachine/012/internal/chart"12+ "github.com/FelineStateMachine/012/internal/sheet"13+)14+15+// HTML pages are drawn in 012's look: the 16 ANSI colors of the16+// reference palettes the golden screens use (e2e/palette_test.go),17+// mapped to the roles the terminal theme gives them18+// (internal/ui/theme), dark or light as the reader's system prefers,19+// in IBM Plex Mono, the docs site's font, embedded so the page needs20+// nothing else. One cell is htmlCellW by htmlCellH pixels, the size of21+// a cell of an SVG chart, so charts sit on the grid as on the screen.22+23+const (24+ htmlCellW = chart.SVGCellW25+ htmlCellH = chart.SVGCellH26+)27+28+//go:embed fonts/*.woff229+var fonts embed.FS30+31+// htmlPalette is a reference palette and the roles drawn in it.32+type htmlPalette struct {33+ ansi [16]uint3234+ bg, fg uint3235+ grid uint32 // the faint lines between cells36+ // ANSI slots of the roles.37+ headerBg, headerFg, bar, muted, border, note, link int38+ series [chart.Colors]int39+}40+41+var htmlPalettes = [2]htmlPalette{{42+ ansi: [16]uint32{0x1d1f21, 0xcc6666, 0xb5bd68, 0xf0c674, 0x81a2be, 0xb294bb, 0x8abeb7, 0xc5c8c6,43+ 0x666666, 0xd54e53, 0xb9ca4a, 0xe7c547, 0x7aa6da, 0xc397d8, 0x70c0b1, 0xeaeaea},44+ bg: 0x1d1f21, fg: 0xc5c8c6, grid: 0x2c2e30,45+ headerBg: 8, headerFg: 15, bar: 6, muted: 8, border: 7, note: 3, link: 12,46+ series: [chart.Colors]int{6, 5, 3, 2, 12, 9},47+}, {48+ ansi: [16]uint32{0x1d1f21, 0xc82829, 0x718c00, 0xb58900, 0x4271ae, 0x8959a8, 0x3e999f, 0xd6d6d6,49+ 0x8e908c, 0xc82829, 0x718c00, 0xb58900, 0x4271ae, 0x8959a8, 0x3e999f, 0xffffff},50+ bg: 0xfafafa, fg: 0x1d1f21, grid: 0xe8e8e8,51+ headerBg: 7, headerFg: 0, bar: 4, muted: 0, border: 0, note: 5, link: 4,52+ series: [chart.Colors]int{4, 5, 2, 1, 6, 8},53+}}54+55+// ruleSlots are the ANSI slots of sheet.Color, as the theme maps them.56+var ruleSlots = [sheet.NumColors]int{0, 1, 3, 2, 6, 4, 5}57+58+// vars writes the palette's CSS variables.59+func (p htmlPalette) vars() string {60+ var b strings.Builder61+ hex := func(v uint32) string { return fmt.Sprintf("#%06x", v) }62+ slot := func(i int) string { return hex(p.ansi[i]) }63+ fmt.Fprintf(&b, "--bg:%s;--fg:%s;--grid:%s;--hbg:%s;--hfg:%s;--bar:%s;--muted:%s;--border:%s;--note:%s;--link:%s;--axis:%s;--acc:%s;--accfg:%s;",64+ hex(p.bg), hex(p.fg), hex(p.grid), slot(p.headerBg), slot(p.headerFg), slot(p.bar), slot(p.muted),65+ slot(p.border), slot(p.note), slot(p.link), slot(8), slot(6), slot(0))66+ for i, s := range p.series {67+ fmt.Fprintf(&b, "--s%d:%s;", i, slot(s))68+ }69+ for c := 1; c < sheet.NumColors; c++ {70+ fill := p.ansi[ruleSlots[c]]71+ fmt.Fprintf(&b, "--r%d:%s;--i%d:%s;", c, hex(fill), c, hex(p.ink(fill)))72+ }73+ return b.String()74+}75+76+// ink is black or bright white, whichever reads better on bg.77+func (p htmlPalette) ink(bg uint32) uint32 {78+ if contrast(p.ansi[0], bg) >= contrast(p.ansi[15], bg) {79+ return p.ansi[0]80+ }81+ return p.ansi[15]82+}83+84+// contrast is the WCAG contrast ratio of two colors.85+func contrast(a, b uint32) float64 {86+ la, lb := luminance(a), luminance(b)87+ return (max(la, lb) + 0.05) / (min(la, lb) + 0.05)88+}89+90+func luminance(c uint32) float64 {91+ ch := func(v uint32) float64 {92+ s := float64(v&0xff) / 25593+ if s <= 0.03928 {94+ return s / 12.9295+ }96+ return math.Pow((s+0.055)/1.055, 2.4)97+ }98+ return 0.2126*ch(c>>16) + 0.7152*ch(c>>8) + 0.0722*ch(c)99+}100+101+// htmlCSS is the style sheet every page and view shares, fonts102+// included.103+var htmlCSS = sync.OnceValue(func() string {104+ var b strings.Builder105+ for _, f := range []struct{ file, weight, style string }{106+ {"ibm-plex-mono-latin-400-normal.woff2", "400", "normal"},107+ {"ibm-plex-mono-latin-600-normal.woff2", "600", "normal"},108+ {"ibm-plex-mono-latin-400-italic.woff2", "400", "italic"},109+ } {110+ data, _ := fonts.ReadFile("fonts/" + f.file)111+ fmt.Fprintf(&b, "@font-face{font-family:'IBM Plex Mono';font-weight:%s;font-style:%s;src:url(data:font/woff2;base64,%s) format('woff2')}\n",112+ f.weight, f.style, base64.StdEncoding.EncodeToString(data))113+ }114+ dark, light := htmlPalettes[0].vars(), htmlPalettes[1].vars()115+ fmt.Fprintf(&b, ":root{%s}\n@media (prefers-color-scheme:light){:root:not(.dark){%s}}\n:root.light{%s}\n", dark, light, light)116+ b.WriteString(htmlRules)117+ return b.String()118+})119+120+// HTMLStyle is the style sheet of 012's HTML pages, for a page built121+// around HTMLGrid or HTMLChart; the root element takes the class dark122+// or light to choose a palette over the reader's preference.123+func HTMLStyle() string { return htmlCSS() }124+125+// htmlRules are the page's rules, after its fonts and palettes.126+const htmlRules = `*{box-sizing:border-box}127+html,body{margin:0;background:var(--bg);color:var(--fg)}128+body{font:15px/21px 'IBM Plex Mono',ui-monospace,'SF Mono',Menlo,Consolas,monospace;font-variant-ligatures:none}129+.o12{min-width:100%;width:max-content}130+.panel{position:sticky;left:0;top:0;z-index:3;background:var(--bg);white-space:pre;width:100vw;max-width:100%}131+.panel>div{height:21px;display:flex;gap:9px;overflow:hidden}132+.panel .book{flex:1;overflow:hidden;text-overflow:ellipsis}133+.mode{background:var(--acc);color:var(--accfg);font-weight:600;padding:0 9px}134+.namebox{background:var(--hbg);color:var(--hfg);min-width:90px;padding:0 9px}135+.input{flex:1;overflow:hidden;text-overflow:ellipsis}136+.context{color:var(--muted)}137+.sheet{position:relative}138+table.grid{border-collapse:collapse;table-layout:fixed;width:max-content}139+.grid th{background:var(--hbg);color:var(--hfg);font-weight:400;height:21px;padding:0;text-align:center;position:sticky;z-index:1}140+.grid thead th{top:63px}141+.grid tbody th{left:0;text-align:right;padding-right:9px;vertical-align:bottom}142+.grid thead th.corner{left:0;z-index:2}143+.grid th.on{background:var(--acc);color:var(--accfg);font-weight:600}144+.grid td{height:21px;padding:0 4px;white-space:pre;overflow:visible;box-shadow:inset -1px -1px 0 var(--grid);vertical-align:bottom;position:relative}145+.grid td.clip{overflow:hidden}146+.grid td.wrap{white-space:pre-wrap;overflow-wrap:anywhere}147+.grid td.r{text-align:right}.grid td.c{text-align:center}.grid td.l{text-align:left}148+.grid td.t{vertical-align:top}.grid td.m{vertical-align:middle}149+.grid td.b{font-weight:600}.grid td.i{font-style:italic}150+.grid td.u{text-decoration:underline}.grid td.s{text-decoration:line-through}.grid td.u.s{text-decoration:underline line-through}151+.grid td.err{text-decoration:underline wavy var(--r1)}152+.grid td.bad{text-decoration:underline dotted var(--r2)}153+.grid td.th{font-weight:600;text-decoration:underline;color:var(--bar)}154+.grid td.band{background:var(--hbg);color:var(--hfg)}155+.grid td.note::after{content:'\259D';position:absolute;right:0;top:-3px;color:var(--note);font-size:12px}156+.grid td.ptr{outline:2px solid var(--acc);outline-offset:-2px}157+.grid a{color:var(--link)}158+.grid .icon{margin-right:9px}159+figure.chart{position:absolute;margin:0;border:1px solid var(--axis);background:var(--bg);padding:20px 17px;z-index:1}160+figure.chart figcaption{position:absolute;top:-11px;left:9px;background:var(--bg);padding:0 9px;font-weight:600;white-space:pre}161+figure.chart .range{position:absolute;bottom:-11px;right:9px;background:var(--bg);padding:0 9px;color:var(--muted);white-space:pre}162+figure.solo{position:relative;display:inline-block;margin:21px 18px}163+svg.chart{display:block;overflow:visible;font:15px 'IBM Plex Mono',ui-monospace,monospace}164+.ct-ax{fill:var(--axis);color:var(--axis)}.ct-lb{fill:var(--muted);color:var(--muted)}.ct-mu{fill:var(--muted);color:var(--muted)}165+.cs-box{stroke:currentColor;stroke-width:1}.cs-gl{stroke:var(--grid);stroke-width:1}.cs-gap{stroke:var(--bg);stroke-width:1.5}166+.ct-s0,.cf-0{fill:var(--s0)}.ct-s1,.cf-1{fill:var(--s1)}.ct-s2,.cf-2{fill:var(--s2)}.ct-s3,.cf-3{fill:var(--s3)}.ct-s4,.cf-4{fill:var(--s4)}.ct-s5,.cf-5{fill:var(--s5)}167+.cs-0{stroke:var(--s0)}.cs-1{stroke:var(--s1)}.cs-2{stroke:var(--s2)}.cs-3{stroke:var(--s3)}.cs-4{stroke:var(--s4)}.cs-5{stroke:var(--s5)}168+.status{white-space:pre;color:var(--muted);padding:0 9px;height:21px}169+`
A internal/fileio/snapdrawn.go +32 -0
M internal/fileio/snapshot.go +7 -0
A internal/headless/charts.go +47 -0
M internal/ui/cmdline.go +1 -1
M oracle/go.mod +5 -0
M oracle/go.sum +10 -0
@@ -6,6 +6,12 @@ github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc
66 github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=77 github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=88 github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=9+github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=10+github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=11+github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=12+github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=13+github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=14+github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=915 github.com/cockroachdb/apd/v3 v3.2.3 h1:4Zx+I3R35bFXMnltzmjP79i2cravE4jTRL6ps9Aux80=1016 github.com/cockroachdb/apd/v3 v3.2.3/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc=1117 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -26,8 +32,12 @@ github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2
2632 github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=2733 github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=2834 github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=35+github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=36+github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=2937 github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=3038 github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=39+github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=40+github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=3141 github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=3242 github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=3343 github.com/parquet-go/bitpack v1.0.0 h1:AUqzlKzPPXf2bCdjfj4sTeacrUwsT7NlcYDMUQxPcQA=