diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9d5ecbc..b92e1e2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,7 +10,7 @@ jobs: build: strategy: matrix: - go-version: [1.20.x] + go-version: [1.21.x] os: [ubuntu-latest] runs-on: ${{ matrix.os }} steps: diff --git a/.gitignore b/.gitignore index d9e18a4..5a81918 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,9 @@ .DS_Store vendor -examples/*.svg + +# Hiding the SVG output files introduces friction when adding a new TXT,SVG pair to the test suite. +# examples/*.svg cmd/tmpl-expand/tmpl-expand cmd/goat/goat diff --git a/README.md b/README.md index d7eff71..9997522 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ => CONCLUSION: Don't use badges. Instead, pay attention to Action result status. --> - + ## What **GoAT** Can Do For You @@ -58,7 +58,7 @@ with rows above and below. ## Installation ``` - $ go install github.com/blampe/goat/cmd/goat@latest + $ go install github.com/dmullis/goat/cmd/goat@latest ``` @@ -176,14 +176,39 @@ Note that '·' above is not ASCII, but rather Unicode, the MIDDLE DOT character, ### Small Grids ``` - ___ ___ .---+---+---+---+---. .---+---+---+---. .---. .---. - ___/ \___/ \ | | | | | | / \ / \ / \ / \ / | +---+ | - / \___/ \___/ +---+---+---+---+---+ +---+---+---+---+ +---+ +---+ - \___/ b \___/ \ | | | b | | | \ / \a/ \b/ \ / \ | +---+ | - / a \___/ \___/ +---+---+---+---+---+ +---+---+---+---+ +---+ b +---+ - \___/ \___/ \ | | a | | | | / \ / \ / \ / \ / | a +---+ | - \___/ \___/ '---+---+---+---+---' '---+---+---+---' '---' '---' - + ___ ___ .---+---+---+---+---. .---+---+---+---. + ___/ \___/ \ | | | | | | / \ / \ / \ / \ / +/ \___/ \___/ +---+---+---+---+---+ +---+---+---+---+ +\___/ b \___/ \ | | | b | | | \ / \a/ \b/ \ / \ +/ a \___/ \___/ +---+---+---+---+---+ +---+---+---+---+ +\___/ \___/ \ | | a | | | | / \ / \ / \ / \ / + \___/ \___/ '---+---+---+---+---' '---+---+---+---' + +.---. .---. +--+ +-+ +-+ .-. .--. .-. +-+ +--+ .-. .--. +| | | | | | | | | | | | | | | | | | | | | | | | ++---+ +---+ +--+ +-+ | | | | | | | | +-+ +--+ '-' '--' +| +---+ | | | | | | | | | | | | | ++---+ b +---+ +--+ +-+ | | | | | | | | +| a +---+ | | | | | | | | | | | | | +'---' '---' +--+ +-+ +-+ '-' '--' '-' + ++---+ .---. .---. +| B | | E | | F | ++---+ '---' '---' +.---. +---+ .---. +| D | | G | | H | +'---' +---+ '---' +0123456789012345678901232 + +Not Supported: + .---+---+---. ++---+ .-. .-. 0 | | a | | b | +| A | | A | | B | 1 +++ +--++-+-++--+ ++---+ '-' '-' 2 | | | | | | | ++---+ .-. .-. 3 +-+ +--++-+-++--+ +| C | | C | | D | 4 | | | | | | ++---+ '-' '-' 5 +++ '---+---+---' +0123434567890123456 | ``` ![](//examples/small-grids.svg) @@ -244,12 +269,12 @@ The core engine of ```goat``` is accessible as a Go library package, for inclusi code of your own. The code implements a subset, and some extensions, of the ASCII diagram generation function of the browser-side Javascript in [Markdeep](http://casual-effects.com/markdeep/). -A nicely formatted reference may be found at [pkg.go.dev](https://pkg.go.dev/github.com/blampe/goat). +A nicely formatted reference may be found at [pkg.go.dev](https://pkg.go.dev/github.com/dmullis/goat). ### Installation ``` - $ go get -u github.com/blampe/goat/ + $ go get -u github.com/dmullis/goat/ ``` ### Library Data Flow ![](//goat.svg) @@ -261,13 +286,13 @@ source file [./goat.go](./goat.go). ### Project Tenets diff --git a/canvas.go b/canvas.go index 20d5c72..fba894a 100644 --- a/canvas.go +++ b/canvas.go @@ -2,7 +2,9 @@ package goat import ( "bufio" + "log" "io" + "os" ) type ( @@ -10,11 +12,10 @@ type ( runeSet map[rune]exists ) - // Characters where more than one line segment can come together. var jointRunes = []rune{ - '.', - '\'', + '.', // possible ... top corner of a 90 degree angle, or curve + '\'', // possible ... bottom corner of a 90 degree angle, or curve '+', '*', 'o', @@ -85,7 +86,7 @@ func isJoint(r rune) bool { return contains(jointRunes, r) } -// XX rename 'isCircle()'? +// XX rename 'isSpot()'? func isDot(r rune) bool { return r == 'o' || r == '*' } @@ -94,23 +95,6 @@ func isTriangle(r rune) bool { return r == '^' || r == 'v' || r == '<' || r == '>' } -// Canvas represents a 2D ASCII rectangle. -type Canvas struct { - // units of cells - Width, Height int - - data map[Index]rune - text map[Index]rune -} - -func (c *Canvas) heightScreen() int { - return c.Height*16 + 8 + 1 -} - -func (c *Canvas) widthScreen() int { - return (c.Width + 1) * 8 -} - // Arg 'canvasMap' is typically either Canvas.data or Canvas.text func inSet(set runeSet, canvasMap map[Index]rune, i Index) (inset bool) { r, inMap := canvasMap[i] @@ -131,6 +115,26 @@ func (c *Canvas) runeAt(i Index) rune { return ' ' } +// Canvas represents a 2D ASCII rectangle. +type Canvas struct { + // units of cells + Width, Height int + + data map[Index]rune + text map[Index]rune +} + +func (c *Canvas) heightScreen() int { + // XX Why " + 8 + 1"? + return c.Height*16 + 8 + 1 +} + +func (c *Canvas) widthScreen() int { + // XX Why "c.Width + 1"? + return (c.Width + 1) * 8 +} + + // NewCanvas creates a fully-populated Canvas according to GoAT-formatted text read from // an io.Reader, consuming all bytes available. func NewCanvas(in io.Reader) (c Canvas) { @@ -154,14 +158,19 @@ func NewCanvas(in io.Reader) (c Canvas) { // https://go.dev/ref/spec#For_statements // But yet, counterintuitively, type of lineStr[_index_] is 'byte'. // https://go.dev/ref/spec#String_types - // XXXX Refactor to use []rune from above. for _, r := range lineStr { //if r > 255 { // fmt.Printf("linestr=\"%s\"\n", lineStr) // fmt.Printf("r == 0x%x\n", r) //} if r == ' ' { - panic("TAB character found on input") + file, isFile := in.(*os.File) + fileName := "unknown" + if isFile { + fileName = file.Name() + } + log.Panicf("\n\tFound TAB in %s, row %d, column %d\n", + fileName, height+1, w) } i := Index{w, height} c.data[i] = r @@ -197,755 +206,6 @@ func (c *Canvas) MoveToText() { } } -// Drawable represents anything that can Draw itself. -type Drawable interface { - draw(out io.Writer) -} - -// Line represents a straight segment between two points 'start' and 'stop', where -// 'start' is either lesser in X (north-east, east, south-east), or -// equal in X and lesser in Y (south). -type Line struct { - start Index - stop Index - - startRune rune - stopRune rune - - // dashed bool - needsNudgingDown bool - needsNudgingLeft bool - needsNudgingRight bool - needsTinyNudgingLeft bool - needsTinyNudgingRight bool - - // This is a line segment all by itself. This centers the segment around - // the midline. - lonely bool - // N or S. Only useful for half steps - chops of this half of the line. - chop Orientation - - // X-major, Y-minor. Therefore, always one of the compass points NE, E, SE, S. - orientation Orientation - - state lineState -} - -type lineState int - -const ( - _Unstarted lineState = iota - _Started -) - -func (l *Line) started() bool { - return l.state == _Started -} - -func (c *Canvas) setStart(l *Line, i Index) { - if l.state == _Unstarted { - l.start = i - l.startRune = c.runeAt(i) - l.stop = i - l.stopRune = c.runeAt(i) - l.state = _Started - } -} - -func (c *Canvas) setStop(l *Line, i Index) { - if l.state == _Started { - l.stop = i - l.stopRune = c.runeAt(i) - } -} - -func (l *Line) goesSomewhere() bool { - return l.start != l.stop -} - -func (l *Line) horizontal() bool { - return l.orientation == E || l.orientation == W -} - -func (l *Line) vertical() bool { - return l.orientation == N || l.orientation == S -} - -func (l *Line) diagonal() bool { - return l.orientation == NE || l.orientation == SE || l.orientation == SW || l.orientation == NW -} - -// XX drop names 'start' below - -// Triangle corresponds to "^", "v", "<" and ">" runes in the absence of -// surrounding alphanumerics. -type Triangle struct { - start Index - orientation Orientation - needsNudging bool -} - -// Circle corresponds to "o" or "*" runes in the absence of surrounding -// alphanumerics. -type Circle struct { - start Index - bold bool -} - -// RoundedCorner corresponds to combinations of "-." or "-'". -type RoundedCorner struct { - start Index - orientation Orientation -} - -// Text corresponds to any runes not reserved for diagrams, or reserved runes -// surrounded by alphanumerics. -type Text struct { - start Index - str string // Possibly multiple bytes, from Unicode source of type 'rune' -} - -// Bridge corresponds to combinations of "-)-" or "-(-" and is displayed as -// the vertical line "hopping over" the horizontal. -type Bridge struct { - start Index - orientation Orientation -} - -// Orientation represents the primary direction that a Drawable is facing. -type Orientation int - -const ( - NONE Orientation = iota // No orientation; no structure present. - N // North - NE // Northeast - NW // Northwest - S // South - SE // Southeast - SW // Southwest - E // East - W // West -) - -// WriteSVGBody writes the entire content of a Canvas out to a stream in SVG format. -func (c *Canvas) WriteSVGBody(dst io.Writer) { - writeBytes(dst, "\n") - - for _, l := range c.Lines() { - l.draw(dst) - } - - for _, tI := range c.Triangles() { - tI.draw(dst) - } - - for _, c := range c.RoundedCorners() { - c.draw(dst) - } - - for _, c := range c.Circles() { - c.draw(dst) - } - - for _, bI := range c.Bridges() { - bI.draw(dst) - } - - writeText(dst, c) - - writeBytes(dst, "\n") -} - -// Lines returns a slice of all Line drawables that we can detect -- in all -// possible orientations. -func (c *Canvas) Lines() (lines []Line) { - horizontalMidlines := c.getLinesForSegment('-') - - diagUpLines := c.getLinesForSegment('/') - for i, l := range diagUpLines { - // /_ - if c.runeAt(l.start.east()) == '_' { - diagUpLines[i].needsTinyNudgingLeft = true - } - - // _ - // / - if c.runeAt(l.stop.north()) == '_' { - diagUpLines[i].needsTinyNudgingRight = true - } - - // _ - // / - if !l.lonely && c.runeAt(l.stop.nEast()) == '_' { - diagUpLines[i].needsTinyNudgingRight = true - } - - // _/ - if !l.lonely && c.runeAt(l.start.west()) == '_' { - diagUpLines[i].needsTinyNudgingLeft = true - } - - // \ - // / - if !l.lonely && c.runeAt(l.stop.north()) == '\\' { - diagUpLines[i].needsTinyNudgingRight = true - } - - // / - // \ - if !l.lonely && c.runeAt(l.start.south()) == '\\' { - diagUpLines[i].needsTinyNudgingLeft = true - } - } - - diagDownLines := c.getLinesForSegment('\\') - for i, l := range diagDownLines { - // _\ - if c.runeAt(l.stop.west()) == '_' { - diagDownLines[i].needsTinyNudgingRight = true - } - - // _ - // \ - if c.runeAt(l.start.north()) == '_' { - diagDownLines[i].needsTinyNudgingLeft = true - } - - // _ - // \ - if !l.lonely && c.runeAt(l.start.nWest()) == '_' { - diagDownLines[i].needsTinyNudgingLeft = true - } - - // \_ - if !l.lonely && c.runeAt(l.stop.east()) == '_' { - diagDownLines[i].needsTinyNudgingRight = true - } - - // \ - // / - if !l.lonely && c.runeAt(l.stop.south()) == '/' { - diagDownLines[i].needsTinyNudgingRight = true - } - - // / - // \ - if !l.lonely && c.runeAt(l.start.north()) == '/' { - diagDownLines[i].needsTinyNudgingLeft = true - } - } - - horizontalBaselines := c.getLinesForSegment('_') - for i, l := range horizontalBaselines { - // TODO: make this nudge an orientation - horizontalBaselines[i].needsNudgingDown = true - - // _ - // _| | - if c.runeAt(l.stop.sEast()) == '|' || c.runeAt(l.stop.nEast()) == '|' { - horizontalBaselines[i].needsNudgingRight = true - } - - // _ - // | _| - if c.runeAt(l.start.sWest()) == '|' || c.runeAt(l.start.nWest()) == '|' { - horizontalBaselines[i].needsNudgingLeft = true - } - - // _ - // _/ \ - if c.runeAt(l.stop.east()) == '/' || c.runeAt(l.stop.sEast()) == '\\' { - horizontalBaselines[i].needsTinyNudgingRight = true - } - - // _ - // \_ / - if c.runeAt(l.start.west()) == '\\' || c.runeAt(l.start.sWest()) == '/' { - horizontalBaselines[i].needsTinyNudgingLeft = true - } - - // _\ - if c.runeAt(l.stop.east()) == '\\' { - horizontalBaselines[i].needsNudgingRight = true - horizontalBaselines[i].needsTinyNudgingRight = true - } - - // - // /_ - if c.runeAt(l.start.west()) == '/' { - horizontalBaselines[i].needsNudgingLeft = true - horizontalBaselines[i].needsTinyNudgingLeft = true - } - // _ - // / - if c.runeAt(l.stop.south()) == '/' { - horizontalBaselines[i].needsTinyNudgingRight = true - } - - // _ - // \ - if c.runeAt(l.start.south()) == '\\' { - horizontalBaselines[i].needsTinyNudgingLeft = true - } - - // _ - // ' - if c.runeAt(l.start.sWest()) == '\'' { - horizontalBaselines[i].needsNudgingLeft = true - } - - // _ - // ' - if c.runeAt(l.stop.sEast()) == '\'' { - horizontalBaselines[i].needsNudgingRight = true - } - } - - verticalLines := c.getLinesForSegment('|') - - lines = append(lines, horizontalMidlines...) - lines = append(lines, horizontalBaselines...) - lines = append(lines, verticalLines...) - lines = append(lines, diagUpLines...) - lines = append(lines, diagDownLines...) - lines = append(lines, c.HalfSteps()...) // vertical, only - - return -} - -func newHalfStep(i Index, chop Orientation) Line { - return Line{ - start: i, - stop: i.south(), - lonely: true, - chop: chop, - orientation: S, - } -} - -func (c *Canvas) HalfSteps() (lines []Line) { - for idx := range upDown(c.Width, c.Height) { - if o := c.partOfHalfStep(idx); o != NONE { - lines = append( - lines, - newHalfStep(idx, o), - ) - } - } - return -} - -func (c *Canvas) getLinesForSegment(segment rune) []Line { - var iter canvasIterator - var orientation Orientation - var passThroughs []rune - - switch segment { - case '-': - iter = leftRight - orientation = E - passThroughs = append(jointRunes, '<', '>', '(', ')') - case '_': - iter = leftRight - orientation = E - passThroughs = append(jointRunes, '|') - case '|': - iter = upDown - orientation = S - passThroughs = append(jointRunes, '^', 'v') - case '/': - iter = diagUp - orientation = NE - passThroughs = append(jointRunes, 'o', '*', '<', '>', '^', 'v', '|') - case '\\': - iter = diagDown - orientation = SE - passThroughs = append(jointRunes, 'o', '*', '<', '>', '^', 'v', '|') - default: - return nil - } - - return c.getLines(iter, segment, passThroughs, orientation) -} - -// ci: the order that we traverse locations on the canvas. -// segment: the primary character we're tracking for this line. -// passThroughs: characters the line segment is allowed to be drawn underneath -// (without terminating the line). -// orientation: the orientation for this line. -func (c *Canvas) getLines( - ci canvasIterator, - segment rune, - passThroughs []rune, - o Orientation, -) (lines []Line) { - // Helper to throw the current line we're tracking on to the slice and - // start a new one. - snip := func(cl Line) Line { - // Only collect lines that actually go somewhere or are isolated - // segments; otherwise, discard what's been collected so far within 'cl'. - if cl.goesSomewhere() { - lines = append(lines, cl) - } - - return Line{orientation: o} - } - - currentLine := Line{orientation: o} - lastSeenRune := ' ' - - for idx := range ci(c.Width+1, c.Height+1) { - r := c.runeAt(idx) - - isSegment := r == segment - isPassThrough := contains(passThroughs, r) - isRoundedCorner := c.isRoundedCorner(idx) - isDot := isDot(r) - isTriangle := isTriangle(r) - - justPassedThrough := contains(passThroughs, lastSeenRune) - - shouldKeep := (isSegment || isPassThrough) && isRoundedCorner == NONE - - // This is an edge case where we have a rounded corner... that's also a - // joint... attached to orthogonal line, e.g.: - // - // '+-- - // | - // - // TODO: This also depends on the orientation of the corner and our - // line. - // NW / NE line can't go with EW/NS lines, vertical is OK though. - if isRoundedCorner != NONE && o != E && (c.partOfVerticalLine(idx) || c.partOfDiagonalLine(idx)) { - shouldKeep = true - } - - // Don't connect | to > for diagonal lines or )) for horizontal lines. - if isPassThrough && justPassedThrough && o != S { - currentLine = snip(currentLine) - } - - // Don't connect o to o, + to o, etc. This character is a new pass-through - // so we still want to respect shouldKeep; we just don't want to draw - // the existing line through this cell. - if justPassedThrough && (isDot || isTriangle) { - currentLine = snip(currentLine) - } - - switch currentLine.state { - case _Unstarted: - if shouldKeep { - c.setStart(¤tLine, idx) - } - case _Started: - if !shouldKeep { - // Snip the existing line, don't add the current cell to it - // *unless* its a line segment all by itself. If it is, keep a - // record that it's an individual segment because we need to - // adjust later in the / and \ cases. - if !currentLine.goesSomewhere() && lastSeenRune == segment { - if !c.partOfRoundedCorner(currentLine.start) { - c.setStop(¤tLine, idx) - currentLine.lonely = true - } - } - currentLine = snip(currentLine) - } else if isPassThrough { - // Snip the existing line but include the current pass-through - // character because we may be continuing the line. - c.setStop(¤tLine, idx) - currentLine = snip(currentLine) - c.setStart(¤tLine, idx) - } else if shouldKeep { - // Keep the line going and extend it by this character. - c.setStop(¤tLine, idx) - } - } - - lastSeenRune = r - } - return -} - -// Triangles detects intended triangles -- typically at the end of an intended line -- -// and returns a representational slice composed of types Triangle and Line. -func (c *Canvas) Triangles() (triangles []Drawable) { - o := NONE - - for idx := range upDown(c.Width, c.Height) { - needsNudging := false - start := idx - - r := c.runeAt(idx) - - if !isTriangle(r) { - continue - } - - // Identify orientation and nudge the triangle to touch any - // adjacent walls. - switch r { - case '^': - o = N - // ^ and ^ - // / \ - if c.runeAt(start.sWest()) == '/' { - o = NE - } else if c.runeAt(start.sEast()) == '\\' { - o = NW - } - case 'v': - if c.runeAt(start.north()) == '|' { - // | - // v - o = S - } else if c.runeAt(start.nEast()) == '/' { - // / - // v - o = SW - } else if c.runeAt(start.nWest()) == '\\' { - // \ - // v - o = SE - } else { - // Conclusion: Meant as a text string 'v', not a triangle - //panic("Not sufficient to fix all 'v' troubles.") - // continue XX Already committed to non-text output for this string? - o = S - } - case '<': - o = W - case '>': - o = E - } - - // Determine if we need to snap the triangle to something and, if so, - // draw a tail if we need to. - switch o { - case N: - r := c.runeAt(start.north()) - if r == '-' || isJoint(r) && !isDot(r) { - needsNudging = true - triangles = append(triangles, newHalfStep(start, N)) - } - case NW: - r := c.runeAt(start.nWest()) - // Need to draw a tail. - if r == '-' || isJoint(r) && !isDot(r) { - needsNudging = true - triangles = append( - triangles, - Line{ - start: start.nWest(), - stop: start, - orientation: SE, - }, - ) - } - case NE: - r := c.runeAt(start.nEast()) - if r == '-' || isJoint(r) && !isDot(r) { - needsNudging = true - triangles = append( - triangles, - Line{ - start: start, - stop: start.nEast(), - orientation: NE, - }, - ) - } - case S: - r := c.runeAt(start.south()) - if r == '-' || isJoint(r) && !isDot(r) { - needsNudging = true - triangles = append(triangles, newHalfStep(start, S)) - } - case SE: - r := c.runeAt(start.sEast()) - if r == '-' || isJoint(r) && !isDot(r) { - needsNudging = true - triangles = append( - triangles, - Line{ - start: start, - stop: start.sEast(), - orientation: SE, - }, - ) - } - case SW: - r := c.runeAt(start.sWest()) - if r == '-' || isJoint(r) && !isDot(r) { - needsNudging = true - triangles = append( - triangles, - Line{ - start: start.sWest(), - stop: start, - orientation: NE, - }, - ) - } - case W: - r := c.runeAt(start.west()) - if isDot(r) { - needsNudging = true - } - case E: - r := c.runeAt(start.east()) - if isDot(r) { - needsNudging = true - } - } - - triangles = append( - triangles, - Triangle{ - start: start, - orientation: o, - needsNudging: needsNudging, - }, - ) - } - return -} - -// Circles returns a slice of all 'o' and '*' characters not considered text. -func (c *Canvas) Circles() (circles []Circle) { - for idx := range upDown(c.Width, c.Height) { - // TODO INCOMING - if c.runeAt(idx) == 'o' { - circles = append(circles, Circle{start: idx}) - } else if c.runeAt(idx) == '*' { - circles = append(circles, Circle{start: idx, bold: true}) - } - } - return -} - -// RoundedCorners returns a slice of all curvy corners in the diagram. -func (c *Canvas) RoundedCorners() (corners []RoundedCorner) { - for idx := range leftRight(c.Width, c.Height) { - if o := c.isRoundedCorner(idx); o != NONE { - corners = append( - corners, - RoundedCorner{start: idx, orientation: o}, - ) - } - } - return -} - -// For . and ' characters this will return a non-NONE orientation if the -// character falls on a rounded corner. -func (c *Canvas) isRoundedCorner(i Index) Orientation { - r := c.runeAt(i) - - if !isJoint(r) { - return NONE - } - - left := i.west() - right := i.east() - lowerLeft := i.sWest() - lowerRight := i.sEast() - upperLeft := i.nWest() - upperRight := i.nEast() - - opensUp := r == '\'' || r == '+' - opensDown := r == '.' || r == '+' - - dashRight := c.runeAt(right) == '-' || c.runeAt(right) == '+' || c.runeAt(right) == '_' || c.runeAt(upperRight) == '_' - dashLeft := c.runeAt(left) == '-' || c.runeAt(left) == '+' || c.runeAt(left) == '_' || c.runeAt(upperLeft) == '_' - - isVerticalSegment := func(i Index) bool { - r := c.runeAt(i) - return r == '|' || r == '+' || r == ')' || r == '(' || isDot(r) - } - - // .- or .- - // | + - if opensDown && dashRight && isVerticalSegment(lowerLeft) { - return NW - } - - // -. or -. or -. or _. or -. - // | + ) ) o - if opensDown && dashLeft && isVerticalSegment(lowerRight) { - return NE - } - - // | or + or | or + or + or_ ) - // -' -' +' +' ++ ' - if opensUp && dashLeft && isVerticalSegment(upperRight) { - return SE - } - - // | or + - // '- '- - if opensUp && dashRight && isVerticalSegment(upperLeft) { - return SW - } - - return NONE -} - -// Text returns a slice of all text characters not belonging to part of the diagram. -// Must be stably sorted, to satisfy regression tests. -func (c *Canvas) Text() (text []Text) { - for idx := range leftRight(c.Width, c.Height) { - r, found := c.text[idx] - if !found { - continue - } - text = append(text, Text{ - start: idx, - str: string(r)}) - } - return -} - -// Bridges returns a slice of all bridges, "-)-" or "-(-", composed as a sequence of -// either type Bridge or type Line. -func (c *Canvas) Bridges() (bridges []Drawable) { - for idx := range leftRight(c.Width, c.Height) { - if o := c.isBridge(idx); o != NONE { - bridges = append( - bridges, - newHalfStep(idx.north(), S), - newHalfStep(idx.south(), N), - Bridge{ - start: idx, - orientation: o, - }, - ) - } - } - return -} - -// -)- or -(- or -func (c *Canvas) isBridge(i Index) Orientation { - r := c.runeAt(i) - - left := c.runeAt(i.west()) - right := c.runeAt(i.east()) - - if left != '-' || right != '-' { - return NONE - } - - if r == '(' { - return W - } - - if r == ')' { - return E - } - - return NONE -} func (c *Canvas) shouldMoveToText(i Index) bool { i_r := c.runeAt(i) diff --git a/clean.sh b/clean.sh new file mode 100755 index 0000000..53cdabf --- /dev/null +++ b/clean.sh @@ -0,0 +1,3 @@ +#! /bin/sh + +rm -f examples/*.svg *.svg README.md diff --git a/drawable.go b/drawable.go new file mode 100644 index 0000000..c76912b --- /dev/null +++ b/drawable.go @@ -0,0 +1,762 @@ +package goat + +import ( + "io" +) + +// Drawable represents anything that can Draw itself. +type Drawable interface { + draw(out io.Writer) +} + +// Line represents a straight segment between two points 'start' and 'stop', where +// 'start' is either lesser in X (north-east, east, south-east), or +// equal in X and lesser in Y (south). +type Line struct { + start Index + stop Index + + startRune rune + stopRune rune + + // dashed bool + needsNudgingDown bool + needsNudgingLeft bool + needsNudgingRight bool + needsTinyNudgingLeft bool + needsTinyNudgingRight bool + + // This is a line segment all by itself. This centers the segment around + // the midline. + lonely bool + // N or S. Only useful for half steps - chops of this half of the line. + chop Orientation + + // X-major, Y-minor. Therefore, always one of the compass points NE, E, SE, S. + orientation Orientation + + state lineState +} + +type lineState int + +const ( + _Unstarted lineState = iota + _Started +) + +func (l *Line) started() bool { + return l.state == _Started +} + +func (c *Canvas) setStart(l *Line, i Index) { + if l.state == _Unstarted { + l.start = i + l.startRune = c.runeAt(i) + l.stop = i + l.stopRune = c.runeAt(i) + l.state = _Started + } +} + +func (c *Canvas) setStop(l *Line, i Index) { + if l.state == _Started { + l.stop = i + l.stopRune = c.runeAt(i) + } +} + +func (l *Line) goesSomewhere() bool { + return l.start != l.stop +} + +func (l *Line) horizontal() bool { + return l.orientation == E || l.orientation == W +} + +func (l *Line) vertical() bool { + return l.orientation == N || l.orientation == S +} + +func (l *Line) diagonal() bool { + return l.orientation == NE || l.orientation == SE || l.orientation == SW || l.orientation == NW +} + +// XX drop names 'start' below + +// Triangle corresponds to "^", "v", "<" and ">" runes in the absence of +// surrounding alphanumerics. +type Triangle struct { + start Index + orientation Orientation + needsNudging bool +} + +// Circle corresponds to "o" or "*" runes in the absence of surrounding +// alphanumerics. +type Circle struct { + start Index + bold bool +} + +// RoundedCorner corresponds to combinations of "-." or "-'". +type RoundedCorner struct { + start Index + orientation Orientation +} + +// Text corresponds to any runes not reserved for diagrams, or reserved runes +// surrounded by alphanumerics. +type Text struct { + start Index + str string // Possibly multiple bytes, from Unicode source of type 'rune' +} + +// Bridge corresponds to combinations of "-)-" or "-(-" and is displayed as +// the vertical line "hopping over" the horizontal. +type Bridge struct { + start Index + orientation Orientation +} + +// Orientation represents the primary direction that a Drawable is facing. +type Orientation int + +const ( + NONE Orientation = iota // No orientation; no structure present. + N // North + NE // Northeast + NW // Northwest + S // South + SE // Southeast + SW // Southwest + E // East + W // West +) + +// WriteSVGBody writes the entire content of a Canvas out to a stream in SVG format. +func (c *Canvas) WriteSVGBody(dst io.Writer) { + writeBytes(dst, "\n") + + for _, l := range c.Lines() { + l.draw(dst) + } + + for _, tI := range c.Triangles() { + tI.draw(dst) + } + + for _, c := range c.RoundedCorners() { + c.draw(dst) + } + + for _, c := range c.Circles() { + c.draw(dst) + } + + for _, bI := range c.Bridges() { + bI.draw(dst) + } + + writeText(dst, c) + + writeBytes(dst, "\n") +} + +// Lines returns a slice of all Line drawables that we can detect -- in all +// possible orientations. +func (c *Canvas) Lines() (lines []Line) { + horizontalMidlines := c.getLinesForSegment('-') + + diagUpLines := c.getLinesForSegment('/') + for i, l := range diagUpLines { + // /_ + if c.runeAt(l.start.east()) == '_' { + diagUpLines[i].needsTinyNudgingLeft = true + } + + // _ + // / + if c.runeAt(l.stop.north()) == '_' { + diagUpLines[i].needsTinyNudgingRight = true + } + + // _ + // / + if !l.lonely && c.runeAt(l.stop.nEast()) == '_' { + diagUpLines[i].needsTinyNudgingRight = true + } + + // _/ + if !l.lonely && c.runeAt(l.start.west()) == '_' { + diagUpLines[i].needsTinyNudgingLeft = true + } + + // \ + // / + if !l.lonely && c.runeAt(l.stop.north()) == '\\' { + diagUpLines[i].needsTinyNudgingRight = true + } + + // / + // \ + if !l.lonely && c.runeAt(l.start.south()) == '\\' { + diagUpLines[i].needsTinyNudgingLeft = true + } + } + + diagDownLines := c.getLinesForSegment('\\') + for i, l := range diagDownLines { + // _\ + if c.runeAt(l.stop.west()) == '_' { + diagDownLines[i].needsTinyNudgingRight = true + } + + // _ + // \ + if c.runeAt(l.start.north()) == '_' { + diagDownLines[i].needsTinyNudgingLeft = true + } + + // _ + // \ + if !l.lonely && c.runeAt(l.start.nWest()) == '_' { + diagDownLines[i].needsTinyNudgingLeft = true + } + + // \_ + if !l.lonely && c.runeAt(l.stop.east()) == '_' { + diagDownLines[i].needsTinyNudgingRight = true + } + + // \ + // / + if !l.lonely && c.runeAt(l.stop.south()) == '/' { + diagDownLines[i].needsTinyNudgingRight = true + } + + // / + // \ + if !l.lonely && c.runeAt(l.start.north()) == '/' { + diagDownLines[i].needsTinyNudgingLeft = true + } + } + + horizontalBaselines := c.getLinesForSegment('_') + for i, l := range horizontalBaselines { + // TODO: make this nudge an orientation + horizontalBaselines[i].needsNudgingDown = true + + // _ + // _| | + if c.runeAt(l.stop.sEast()) == '|' || c.runeAt(l.stop.nEast()) == '|' { + horizontalBaselines[i].needsNudgingRight = true + } + + // _ + // | _| + if c.runeAt(l.start.sWest()) == '|' || c.runeAt(l.start.nWest()) == '|' { + horizontalBaselines[i].needsNudgingLeft = true + } + + // _ + // _/ \ + if c.runeAt(l.stop.east()) == '/' || c.runeAt(l.stop.sEast()) == '\\' { + horizontalBaselines[i].needsTinyNudgingRight = true + } + + // _ + // \_ / + if c.runeAt(l.start.west()) == '\\' || c.runeAt(l.start.sWest()) == '/' { + horizontalBaselines[i].needsTinyNudgingLeft = true + } + + // _\ + if c.runeAt(l.stop.east()) == '\\' { + horizontalBaselines[i].needsNudgingRight = true + horizontalBaselines[i].needsTinyNudgingRight = true + } + + // + // /_ + if c.runeAt(l.start.west()) == '/' { + horizontalBaselines[i].needsNudgingLeft = true + horizontalBaselines[i].needsTinyNudgingLeft = true + } + // _ + // / + if c.runeAt(l.stop.south()) == '/' { + horizontalBaselines[i].needsTinyNudgingRight = true + } + + // _ + // \ + if c.runeAt(l.start.south()) == '\\' { + horizontalBaselines[i].needsTinyNudgingLeft = true + } + + // _ + // ' + if c.runeAt(l.start.sWest()) == '\'' { + horizontalBaselines[i].needsNudgingLeft = true + } + + // _ + // ' + if c.runeAt(l.stop.sEast()) == '\'' { + horizontalBaselines[i].needsNudgingRight = true + } + } + + verticalLines := c.getLinesForSegment('|') + + lines = append(lines, horizontalMidlines...) + lines = append(lines, horizontalBaselines...) + lines = append(lines, verticalLines...) + lines = append(lines, diagUpLines...) + lines = append(lines, diagDownLines...) + lines = append(lines, c.HalfSteps()...) // vertical, only + + return +} + +func newHalfStep(i Index, chop Orientation) Line { + return Line{ + start: i, + stop: i.south(), + lonely: true, + chop: chop, + orientation: S, + } +} + +func (c *Canvas) HalfSteps() (lines []Line) { + for idx := range upDown(c.Width, c.Height) { + if o := c.partOfHalfStep(idx); o != NONE { + lines = append( + lines, + newHalfStep(idx, o), + ) + } + } + return +} + +func (c *Canvas) getLinesForSegment(segment rune) []Line { + var iter canvasIterator + var orientation Orientation + var passThroughs []rune + + switch segment { + case '-': + iter = leftRight + orientation = E + passThroughs = append(jointRunes, '<', '>', '(', ')') + case '_': + iter = leftRight + orientation = E + passThroughs = append(jointRunes, '|') + case '|': + iter = upDown + orientation = S + passThroughs = append(jointRunes, '^', 'v') + case '/': + iter = diagUp + orientation = NE + passThroughs = append(jointRunes, 'o', '*', '<', '>', '^', 'v', '|') + case '\\': + iter = diagDown + orientation = SE + passThroughs = append(jointRunes, 'o', '*', '<', '>', '^', 'v', '|') + default: + return nil + } + + return c.getLines(segment, iter, orientation, passThroughs) +} + +// segment: the primary character expected along a continuing Line +// ci: the order that the loop below traverse locations on the canvas. +// orientation: the orientation for this line. +// passThroughs: characters that will produce a mark that the line segment +// is allowed to be drawn either through or, in the case of 'o', "underneath" -- +// without terminating the line. +func (c *Canvas) getLines( + segment rune, + ci canvasIterator, + o Orientation, + passThroughs []rune, +) (lines []Line) { + // Helper to throw the current line we're tracking on to the slice and + // start a new one. + snip := func(cl Line) Line { + // Only collect lines that actually go somewhere or are isolated + // segments; otherwise, discard what's been collected so far within 'cl'. + if cl.goesSomewhere() { + lines = append(lines, cl) + } + + return Line{orientation: o} + } + + currentLine := Line{orientation: o} + lastSeenRune := ' ' + + // X Purpose of the '+1' overscan is to reset lastSeenRune to ' ' upon wrapping the minor axis. + for idx := range ci(c.Width+1, c.Height+1) { + r := c.runeAt(idx) + + isSegment := r == segment + isPassThrough := contains(passThroughs, r) + isRoundedCorner := c.isRoundedCorner(idx) + isDot := isDot(r) + isTriangle := isTriangle(r) + + justPassedThrough := contains(passThroughs, lastSeenRune) + + shouldKeep := (isSegment || isPassThrough) && isRoundedCorner == NONE + + // This is an edge case where we have a rounded corner... that's also a + // joint... attached to orthogonal line, e.g.: + // + // '+-- + // | + // + // TODO: This also depends on the orientation of the corner and our + // line. + // NW / NE line can't go with EW/NS lines, vertical is OK though. + if isRoundedCorner != NONE && o != E && (c.partOfVerticalLine(idx) || c.partOfDiagonalLine(idx)) { + shouldKeep = true + } + + // Don't connect | to > for diagonal lines or )) for horizontal lines. + if isPassThrough && justPassedThrough && o != S { + currentLine = snip(currentLine) + } + + // Don't connect o to o, + to o, etc. This character is a new pass-through + // so we still want to respect shouldKeep; we just don't want to draw + // the existing line through this cell. + if justPassedThrough && (isDot || isTriangle) { + currentLine = snip(currentLine) + } + + if o == S && (r == '.' || lastSeenRune == '\'') { + currentLine = snip(currentLine) + } + + switch currentLine.state { + case _Unstarted: + if shouldKeep { + c.setStart(¤tLine, idx) + } + case _Started: + if !shouldKeep { + // Snip the existing line, don't add the current cell to it + // *unless* its a line segment all by itself. If it is, keep a + // record that it's an individual segment because we need to + // adjust later in the / and \ cases. + if !currentLine.goesSomewhere() && lastSeenRune == segment { + if !c.partOfRoundedCorner(currentLine.start) { + c.setStop(¤tLine, idx) + currentLine.lonely = true + } + } + currentLine = snip(currentLine) + } else if isPassThrough { + // Snip the existing line but include the current pass-through + // character because we may be continuing the line. + c.setStop(¤tLine, idx) + currentLine = snip(currentLine) + c.setStart(¤tLine, idx) + } else if shouldKeep { + // Keep the line going and extend it by this character. + c.setStop(¤tLine, idx) + } + } + + lastSeenRune = r + } + return +} + +// Triangles detects intended triangles -- typically at the end of an intended line -- +// and returns a representational slice composed of types Triangle and Line. +func (c *Canvas) Triangles() (triangles []Drawable) { + o := NONE + + for idx := range upDown(c.Width, c.Height) { + needsNudging := false + start := idx + + r := c.runeAt(idx) + + if !isTriangle(r) { + continue + } + + // Identify orientation and nudge the triangle to touch any + // adjacent walls. + switch r { + case '^': + o = N + // ^ and ^ + // / \ + if c.runeAt(start.sWest()) == '/' { + o = NE + } else if c.runeAt(start.sEast()) == '\\' { + o = NW + } + case 'v': + if c.runeAt(start.north()) == '|' { + // | + // v + o = S + } else if c.runeAt(start.nEast()) == '/' { + // / + // v + o = SW + } else if c.runeAt(start.nWest()) == '\\' { + // \ + // v + o = SE + } else { + // Conclusion: Meant as a text string 'v', not a triangle + //panic("Not sufficient to fix all 'v' troubles.") + // continue XX Already committed to non-text output for this string? + o = S + } + case '<': + o = W + case '>': + o = E + } + + // Determine if we need to snap the triangle to something and, if so, + // draw a tail if we need to. + switch o { + case N: + r := c.runeAt(start.north()) + if r == '-' || isJoint(r) && !isDot(r) { + needsNudging = true + triangles = append(triangles, newHalfStep(start, N)) + } + case NW: + r := c.runeAt(start.nWest()) + // Need to draw a tail. + if r == '-' || isJoint(r) && !isDot(r) { + needsNudging = true + triangles = append( + triangles, + Line{ + start: start.nWest(), + stop: start, + orientation: SE, + }, + ) + } + case NE: + r := c.runeAt(start.nEast()) + if r == '-' || isJoint(r) && !isDot(r) { + needsNudging = true + triangles = append( + triangles, + Line{ + start: start, + stop: start.nEast(), + orientation: NE, + }, + ) + } + case S: + r := c.runeAt(start.south()) + if r == '-' || isJoint(r) && !isDot(r) { + needsNudging = true + triangles = append(triangles, newHalfStep(start, S)) + } + case SE: + r := c.runeAt(start.sEast()) + if r == '-' || isJoint(r) && !isDot(r) { + needsNudging = true + triangles = append( + triangles, + Line{ + start: start, + stop: start.sEast(), + orientation: SE, + }, + ) + } + case SW: + r := c.runeAt(start.sWest()) + if r == '-' || isJoint(r) && !isDot(r) { + needsNudging = true + triangles = append( + triangles, + Line{ + start: start.sWest(), + stop: start, + orientation: NE, + }, + ) + } + case W: + r := c.runeAt(start.west()) + if isDot(r) { + needsNudging = true + } + case E: + r := c.runeAt(start.east()) + if isDot(r) { + needsNudging = true + } + } + + triangles = append( + triangles, + Triangle{ + start: start, + orientation: o, + needsNudging: needsNudging, + }, + ) + } + return +} + +// Circles returns a slice of all 'o' and '*' characters not considered text. +func (c *Canvas) Circles() (circles []Circle) { + for idx := range upDown(c.Width, c.Height) { + // TODO INCOMING + if c.runeAt(idx) == 'o' { + circles = append(circles, Circle{start: idx}) + } else if c.runeAt(idx) == '*' { + circles = append(circles, Circle{start: idx, bold: true}) + } + } + return +} + +// RoundedCorners returns a slice of all curvy corners in the diagram. +func (c *Canvas) RoundedCorners() (corners []RoundedCorner) { + for idx := range leftRight(c.Width, c.Height) { + if o := c.isRoundedCorner(idx); o != NONE { + corners = append( + corners, + RoundedCorner{start: idx, orientation: o}, + ) + } + } + return +} + +// For . and ' characters this will return a non-NONE orientation if the +// character falls on a rounded corner. +func (c *Canvas) isRoundedCorner(i Index) Orientation { + r := c.runeAt(i) + + if !isJoint(r) { + return NONE + } + + left := i.west() + right := i.east() + lowerLeft := i.sWest() + lowerRight := i.sEast() + upperLeft := i.nWest() + upperRight := i.nEast() + + opensUp := r == '\'' || r == '+' + opensDown := r == '.' || r == '+' + + dashRight := c.runeAt(right) == '-' || c.runeAt(right) == '+' || c.runeAt(right) == '_' || c.runeAt(upperRight) == '_' + dashLeft := c.runeAt(left) == '-' || c.runeAt(left) == '+' || c.runeAt(left) == '_' || c.runeAt(upperLeft) == '_' + + isVerticalSegment := func(i Index) bool { + r := c.runeAt(i) + return r == '|' || r == '+' || r == ')' || r == '(' || isDot(r) + } + + // .- or .- + // | + + if opensDown && dashRight && isVerticalSegment(lowerLeft) { + return NW + } + + // -. or -. or -. or _. or -. + // | + ) ) o + if opensDown && dashLeft && isVerticalSegment(lowerRight) { + return NE + } + + // | or + or | or + or + or_ ) + // -' -' +' +' ++ ' + if opensUp && dashLeft && isVerticalSegment(upperRight) { + return SE + } + + // | or + + // '- '- + if opensUp && dashRight && isVerticalSegment(upperLeft) { + return SW + } + + return NONE +} + +// Text returns a slice of all text characters not belonging to part of the diagram. +// Must be stably sorted, to satisfy regression tests. +func (c *Canvas) Text() (text []Text) { + for idx := range leftRight(c.Width, c.Height) { + r, found := c.text[idx] + if !found { + continue + } + text = append(text, Text{ + start: idx, + str: string(r)}) + } + return +} + +// Bridges returns a slice of all bridges, "-)-" or "-(-", composed as a sequence of +// either type Bridge or type Line. +func (c *Canvas) Bridges() (bridges []Drawable) { + for idx := range leftRight(c.Width, c.Height) { + if o := c.isBridge(idx); o != NONE { + bridges = append( + bridges, + newHalfStep(idx.north(), S), + newHalfStep(idx.south(), N), + Bridge{ + start: idx, + orientation: o, + }, + ) + } + } + return +} + +// -)- or -(- or +func (c *Canvas) isBridge(i Index) Orientation { + r := c.runeAt(i) + + left := c.runeAt(i.west()) + right := c.runeAt(i.east()) + + if left != '-' || right != '-' { + return NONE + } + + if r == '(' { + return W + } + + if r == ')' { + return E + } + + return NONE +} + diff --git a/examples-regression_test.go b/examples-regression_test.go new file mode 100644 index 0000000..2eaff49 --- /dev/null +++ b/examples-regression_test.go @@ -0,0 +1,205 @@ +package goat + +import ( + "bytes" + "errors" + "flag" + //"fmt" + "io" + "io/ioutil" + "os" + "path/filepath" + "strings" + "testing" + "text/template" +) + +const ( + examplesDir = "examples" +) + +var ( + write = flag.Bool("write", + false, "write reference SVG output files") + svgColorLightScheme = flag.String("svg-color-light-scheme", "#000000", + `See help for cmd/goat`) + svgColorDarkScheme = flag.String("svg-color-dark-scheme", "#FFFFFF", + `See help for cmd/goat`) + + // Begin the directory name with '_' to hide from git. + svgDeltaDir = flag.String("svg-delta-dir", "_examples_new", + `Directory to be filled with a delta-image file for each +newly-generated SVG that does not match those in ` + examplesDir) +) + +func TestExamples(t *testing.T) { + // XX This sweeps up ~every~ *.txt file in examples/ + txtPaths, err := filepath.Glob(filepath.Join(examplesDir, "*.txt")) + if err != nil { + t.Fatal(err) + } + + baseNames := make([]string, len(txtPaths)) + for i := range txtPaths { + baseName, found := strings.CutPrefix(txtPaths[i], examplesDir+"/") + if !found { + panic("Could not cut prefix from pathname.") + } + baseNames[i] = baseName + } + + if *write { + writeExamples(examplesDir, examplesDir, baseNames, *svgColorLightScheme, *svgColorDarkScheme) + } else { + t.Logf("Verifying equality of current SVG with examples/ references.\n") + verifyExamples(t, examplesDir, baseNames) + } +} + + +func writeExamples(inDir, outDir string, baseNames []string, lightColor, darkColor string) { + for _, name := range baseNames { + in := getIn(inDir + "/" + name) + out := getOut(outDir + "/" + name) + BuildAndWriteSVG(in, out, lightColor, darkColor) + in.Close() + out.Close() + } +} + +func verifyExamples(t *testing.T, examplesDir string, baseNames []string) { + var failures []string + for _, name := range baseNames { + in := getIn(examplesDir + "/" + name) + buff := &bytes.Buffer{} + BuildAndWriteSVG(in, buff, *svgColorLightScheme, *svgColorDarkScheme) + in.Close() + if nil != compareSVG(t, buff, examplesDir, name) { + failures = append(failures, name) + } + + } + if len(failures) > 0 { + t.Logf(`Failed to verify contents of %d .svg files`, + len(failures)) + err := os.Mkdir(*svgDeltaDir, 0770) + if err != nil { + t.Fatalf(` + Aborting: "%v"`, err) + } + writeExamples(examplesDir, *svgDeltaDir, failures, "#000088", "#88CCFF") + writeDeltaHTML(t, "../" + examplesDir, *svgDeltaDir, failures) + t.FailNow() + } +} + +func compareSVG(t *testing.T, buff *bytes.Buffer, examplesDir string, baseName string) error { + fileName := examplesDir + "/" + baseName + golden, err := getOutString(fileName) + if err != nil { + t.Log(err) + } + if newStr := buff.String(); newStr != golden { + // Skip complaint if the modification timestamp of the .txt file + // source is fresher than that of the .svg? + // => NO, Any .txt difference might be an editing mistake. + + t.Logf("Content mismatch for %s. Length was %d, expected %d", + toSVGFilename(fileName), buff.Len(), len(golden)) + for i:=0; i +.blended-images { + height: 100%; /* XX How to make equal to pixel bounds of the SVGs? */ + background-size: contain, contain; + background-repeat: no-repeat; + background-blend-mode: difference; + background-image: url('{{.ExamplesDir}}/{{.SvgBaseName}}'), url('{{.DeltaDir}}/{{.SvgBaseName}}'); + } + + +
+
+
+`)) + for _, name := range baseNames { + htmlOutName := stripSuffix(name) + ".html" + t.Logf("\t%s", htmlOutName) + htmlOutFile, err := os.Create(deltaDir + "/" + htmlOutName) + err = tmpl.Execute(htmlOutFile, map[string]string{ + "ExamplesDir": examplesDir, + "DeltaDir": ".", + "SvgBaseName": toSVGFilename(name), + }) + htmlOutFile.Close() + if err != nil { + panic(err) + } + } +} + +func getIn(txtFilename string) io.ReadCloser { + in, err := os.Open(txtFilename) + if err != nil { + panic(err) + } + return in +} + +func getOutExport(pathPrefix, txtBaseName string) io.WriteCloser { + svgBaseName := toSVGFilename(txtBaseName) + out, err := os.Create(pathPrefix + svgBaseName) + if err != nil { + panic(err) + } + return out +} + +func getOut(txtFilename string) io.WriteCloser { + out, err := os.Create(toSVGFilename(txtFilename)) + if err != nil { + panic(err) + } + return out +} + +func getOutString(txtFilename string) (string, error) { + b, err := ioutil.ReadFile(toSVGFilename(txtFilename)) + if err != nil { + // XX Simply panic rather than return an error? + return "", err + } + // XX Why are there RETURN characters in contents of the .SVG files? + b = bytes.ReplaceAll(b, []byte("\r\n"), []byte("\n")) + return string(b), nil +} + +func toSVGFilename(txtFilename string) string { + return strings.TrimSuffix(txtFilename, filepath.Ext(txtFilename)) + ".svg" +} + +func stripSuffix(basename string) string { + return strings.Split(basename,".")[0] +} diff --git a/examples/arrows.svg b/examples/arrows.svg index f0fcafa..69f9ce9 100644 --- a/examples/arrows.svg +++ b/examples/arrows.svg @@ -5,6 +5,7 @@ svg { } @media (prefers-color-scheme: dark) { svg { + color-scheme: dark; /* ask the browser for a dark background */ color: #FFFFFF; } } diff --git a/examples/big-grids.svg b/examples/big-grids.svg index fb32bfd..4a07d1e 100644 --- a/examples/big-grids.svg +++ b/examples/big-grids.svg @@ -5,6 +5,7 @@ svg { } @media (prefers-color-scheme: dark) { svg { + color-scheme: dark; /* ask the browser for a dark background */ color: #FFFFFF; } } diff --git a/examples/big-shapes.svg b/examples/big-shapes.svg index 4a54725..a7ed0d5 100644 --- a/examples/big-shapes.svg +++ b/examples/big-shapes.svg @@ -5,6 +5,7 @@ svg { } @media (prefers-color-scheme: dark) { svg { + color-scheme: dark; /* ask the browser for a dark background */ color: #FFFFFF; } } diff --git a/examples/block-characters.svg b/examples/block-characters.svg new file mode 100644 index 0000000..56c67d0 --- /dev/null +++ b/examples/block-characters.svg @@ -0,0 +1,55 @@ + + + + +S +p +e +c +i +a +l +c +a +s +e +s +s +u +p +p +o +r +t +e +d +b +y +M +a +r +k +d +e +e +p +: + + diff --git a/examples/block-characters.txt b/examples/block-characters.txt new file mode 100644 index 0000000..27a2820 --- /dev/null +++ b/examples/block-characters.txt @@ -0,0 +1,3 @@ +Special cases supported by Markdeep: + +▉ ▓ ▒ ░ diff --git a/examples/circle.svg b/examples/circle.svg index 54d61df..09ea07b 100644 --- a/examples/circle.svg +++ b/examples/circle.svg @@ -5,6 +5,7 @@ svg { } @media (prefers-color-scheme: dark) { svg { + color-scheme: dark; /* ask the browser for a dark background */ color: #FFFFFF; } } diff --git a/examples/circuits.svg b/examples/circuits.svg index e1ccde2..9c9b0a8 100644 --- a/examples/circuits.svg +++ b/examples/circuits.svg @@ -5,6 +5,7 @@ svg { } @media (prefers-color-scheme: dark) { svg { + color-scheme: dark; /* ask the browser for a dark background */ color: #FFFFFF; } } diff --git a/examples/complicated.svg b/examples/complicated.svg index 5ebca64..0bb438a 100644 --- a/examples/complicated.svg +++ b/examples/complicated.svg @@ -5,6 +5,7 @@ svg { } @media (prefers-color-scheme: dark) { svg { + color-scheme: dark; /* ask the browser for a dark background */ color: #FFFFFF; } } @@ -133,7 +134,6 @@ svg { - diff --git a/examples/debug.svg b/examples/debug.svg new file mode 100644 index 0000000..3184e56 --- /dev/null +++ b/examples/debug.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +a +( +) +a +( +) +( +) +( +) + + diff --git a/examples/debug.txt b/examples/debug.txt new file mode 100644 index 0000000..d9bdf2a --- /dev/null +++ b/examples/debug.txt @@ -0,0 +1,34 @@ + + ( + + ( + + ) + + ( + + a() + + a() + + () + + () + + || + --))-- + || + + || + --((-- + || + + | | +--)-)-- + | | + + | | +--(-(-- + | | + +o- diff --git a/examples/dot-grids.svg b/examples/dot-grids.svg index 9648aaf..6c46cc7 100644 --- a/examples/dot-grids.svg +++ b/examples/dot-grids.svg @@ -5,6 +5,7 @@ svg { } @media (prefers-color-scheme: dark) { svg { + color-scheme: dark; /* ask the browser for a dark background */ color: #FFFFFF; } } diff --git a/examples/edge-cases.svg b/examples/edge-cases.svg index df810d6..aa8370c 100644 --- a/examples/edge-cases.svg +++ b/examples/edge-cases.svg @@ -5,6 +5,7 @@ svg { } @media (prefers-color-scheme: dark) { svg { + color-scheme: dark; /* ask the browser for a dark background */ color: #FFFFFF; } } diff --git a/examples/flow-chart.svg b/examples/flow-chart.svg index 4a84530..6f575e7 100644 --- a/examples/flow-chart.svg +++ b/examples/flow-chart.svg @@ -5,6 +5,7 @@ svg { } @media (prefers-color-scheme: dark) { svg { + color-scheme: dark; /* ask the browser for a dark background */ color: #FFFFFF; } } diff --git a/examples/graphics.svg b/examples/graphics.svg index e2ee3ba..eb36223 100644 --- a/examples/graphics.svg +++ b/examples/graphics.svg @@ -5,6 +5,7 @@ svg { } @media (prefers-color-scheme: dark) { svg { + color-scheme: dark; /* ask the browser for a dark background */ color: #FFFFFF; } } diff --git a/examples/half-step-bugs.svg b/examples/half-step-bugs.svg new file mode 100644 index 0000000..1b4a124 --- /dev/null +++ b/examples/half-step-bugs.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + +d +o +u +b +l +e +- +d +r +a +w +g +a +p +a +t +a +b +u +t +t +i +n +g +l +i +n +e +e +n +d +s + + diff --git a/examples/half-step-bugs.txt b/examples/half-step-bugs.txt new file mode 100644 index 0000000..1c9abb9 --- /dev/null +++ b/examples/half-step-bugs.txt @@ -0,0 +1,12 @@ + + _ +|_| + +double-draw + + + _ +| | +|_| + +gap at abutting line ends diff --git a/examples/hollow-circle.svg b/examples/hollow-circle.svg new file mode 100644 index 0000000..8e2f285 --- /dev/null +++ b/examples/hollow-circle.svg @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +0 +1 +2 +3 +4 +5 +6 +x +x +x +x +x +x +o +o +o +o +* +* +* +* + + diff --git a/examples/hollow-circle.txt b/examples/hollow-circle.txt new file mode 100644 index 0000000..f821866 --- /dev/null +++ b/examples/hollow-circle.txt @@ -0,0 +1,50 @@ +-o + * * o o + 0123456 + + + +->o o<- + +-> x x <- + xxxx + + <---> + <---> + + o<--->o + o<--->o + + + <--> + <--> + + o<-->o + o<-->o + + + <-> < > + <-> < > + + o<->o o< >o + o<->o o< >o + + + <> + <> + + o<>o + o<>o + + +oo +^^ +|| +vv +oo + +** -- +^^ +|| +vv +** -- diff --git a/examples/icons.svg b/examples/icons.svg index dd32e66..97f002f 100644 --- a/examples/icons.svg +++ b/examples/icons.svg @@ -5,6 +5,7 @@ svg { } @media (prefers-color-scheme: dark) { svg { + color-scheme: dark; /* ask the browser for a dark background */ color: #FFFFFF; } } diff --git a/examples/incompatibilities.svg b/examples/incompatibilities.svg new file mode 100644 index 0000000..31ceaa3 --- /dev/null +++ b/examples/incompatibilities.svg @@ -0,0 +1,355 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +I +n +p +u +t +T +X +T +p +a +t +t +e +r +n +s +s +u +p +p +o +r +t +e +d +b +y +M +a +r +k +D +e +e +p +, +b +u +t +n +o +t +b +y +G +o +a +t +. +H +o +l +l +o +w +c +i +r +c +l +e +s +R +e +n +d +e +r +e +d +t +o +S +V +G +a +s +a +" +h +o +l +l +o +w +" +c +i +r +c +l +e +, +t +r +a +n +s +p +a +r +e +n +t +t +o +b +a +c +k +g +r +o +u +n +d +. +G +o +a +t +- +s +p +e +c +i +f +i +c +a +l +t +e +r +n +a +t +i +v +e +r +e +n +d +e +r +i +n +g +o +p +t +i +o +n +s +a +r +e +a +v +a +i +l +a +b +l +e +o +n +t +h +e +c +o +m +m +a +n +d +l +i +n +e +. +A +l +t +e +r +n +a +t +i +v +e +T +X +T +p +a +t +t +e +r +n +s +t +o +i +n +d +i +c +a +t +e +d +o +u +b +l +e +- +w +i +d +t +h +c +i +r +c +l +e +s +: +G +o +a +t +a +n +d +M +a +r +k +D +e +e +p +M +a +r +k +D +e +e +p +o +n +l +y +M +a +r +k +D +e +e +p +o +n +l +y +P +a +r +a +l +l +e +l +a +r +c +s +. +. +) +) +M +a +r +k +D +e +e +p +o +n +l +y + + diff --git a/examples/incompatibilities.txt b/examples/incompatibilities.txt new file mode 100644 index 0000000..a5828bd --- /dev/null +++ b/examples/incompatibilities.txt @@ -0,0 +1,31 @@ +Input TXT patterns supported by MarkDeep, but not by Goat. + + +Hollow circles + + o + + Rendered to SVG as a "hollow" circle, transparent to background. + Goat-specific alternative rendering options are available on the command line. + + +Alternative TXT patterns to indicate double-width circles: + + .-. + | | Goat and MarkDeep + '-' + + +-+ + + + MarkDeep only + +-+ + + +-+ + | | MarkDeep only + +-+ + + +Parallel arcs + + _..---. + __)) )- MarkDeep only + ''---' diff --git a/examples/large-nodes.svg b/examples/large-nodes.svg index 7b7c174..592c524 100644 --- a/examples/large-nodes.svg +++ b/examples/large-nodes.svg @@ -5,6 +5,7 @@ svg { } @media (prefers-color-scheme: dark) { svg { + color-scheme: dark; /* ask the browser for a dark background */ color: #FFFFFF; } } diff --git a/examples/line-decorations.svg b/examples/line-decorations.svg index 8275285..3172ecb 100644 --- a/examples/line-decorations.svg +++ b/examples/line-decorations.svg @@ -5,6 +5,7 @@ svg { } @media (prefers-color-scheme: dark) { svg { + color-scheme: dark; /* ask the browser for a dark background */ color: #FFFFFF; } } diff --git a/examples/line-ends.svg b/examples/line-ends.svg index 5552eb6..85dd638 100644 --- a/examples/line-ends.svg +++ b/examples/line-ends.svg @@ -5,6 +5,7 @@ svg { } @media (prefers-color-scheme: dark) { svg { + color-scheme: dark; /* ask the browser for a dark background */ color: #FFFFFF; } } diff --git a/examples/overlaps.svg b/examples/overlaps.svg index c28f1aa..2db48fc 100644 --- a/examples/overlaps.svg +++ b/examples/overlaps.svg @@ -5,6 +5,7 @@ svg { } @media (prefers-color-scheme: dark) { svg { + color-scheme: dark; /* ask the browser for a dark background */ color: #FFFFFF; } } diff --git a/examples/radius16circle.svg b/examples/radius16circle.svg new file mode 100644 index 0000000..1996512 --- /dev/null +++ b/examples/radius16circle.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + diff --git a/examples/radius16circle.txt b/examples/radius16circle.txt new file mode 100644 index 0000000..99747b9 --- /dev/null +++ b/examples/radius16circle.txt @@ -0,0 +1,9 @@ + -+ + + + + + + -+ + + +-+ + + + + +-+ diff --git a/examples/regression.svg b/examples/regression.svg index 5d26ed7..0d716e3 100644 --- a/examples/regression.svg +++ b/examples/regression.svg @@ -5,6 +5,7 @@ svg { } @media (prefers-color-scheme: dark) { svg { + color-scheme: dark; /* ask the browser for a dark background */ color: #FFFFFF; } } @@ -104,7 +105,7 @@ svg { - + diff --git a/examples/small-grids.svg b/examples/small-grids.svg index d652a14..529a066 100644 --- a/examples/small-grids.svg +++ b/examples/small-grids.svg @@ -1,167 +1,313 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -b -b -a -b -a -b -a -a +b +b +a +b +a +a +b +a +B +E +F +D +G +H +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +0 +1 +2 +3 +2 +N +o +t +S +u +p +p +o +r +t +e +d +: +0 +a +b +A +A +B +1 +2 +3 +C +C +D +4 +5 +0 +1 +2 +3 +4 +3 +4 +5 +6 +7 +8 +9 +0 +1 +2 +3 +4 +5 +6 diff --git a/examples/small-grids.txt b/examples/small-grids.txt index 1e95734..e848d4b 100644 --- a/examples/small-grids.txt +++ b/examples/small-grids.txt @@ -1,8 +1,33 @@ - ___ ___ .---+---+---+---+---. .---+---+---+---. .---. .---. - ___/ \___/ \ | | | | | | / \ / \ / \ / \ / | +---+ | - / \___/ \___/ +---+---+---+---+---+ +---+---+---+---+ +---+ +---+ - \___/ b \___/ \ | | | b | | | \ / \a/ \b/ \ / \ | +---+ | - / a \___/ \___/ +---+---+---+---+---+ +---+---+---+---+ +---+ b +---+ - \___/ \___/ \ | | a | | | | / \ / \ / \ / \ / | a +---+ | - \___/ \___/ '---+---+---+---+---' '---+---+---+---' '---' '---' + ___ ___ .---+---+---+---+---. .---+---+---+---. + ___/ \___/ \ | | | | | | / \ / \ / \ / \ / +/ \___/ \___/ +---+---+---+---+---+ +---+---+---+---+ +\___/ b \___/ \ | | | b | | | \ / \a/ \b/ \ / \ +/ a \___/ \___/ +---+---+---+---+---+ +---+---+---+---+ +\___/ \___/ \ | | a | | | | / \ / \ / \ / \ / + \___/ \___/ '---+---+---+---+---' '---+---+---+---' +.---. .---. +--+ +-+ +-+ .-. .--. .-. +-+ +--+ .-. .--. +| | | | | | | | | | | | | | | | | | | | | | | | ++---+ +---+ +--+ +-+ | | | | | | | | +-+ +--+ '-' '--' +| +---+ | | | | | | | | | | | | | ++---+ b +---+ +--+ +-+ | | | | | | | | +| a +---+ | | | | | | | | | | | | | +'---' '---' +--+ +-+ +-+ '-' '--' '-' + ++---+ .---. .---. +| B | | E | | F | ++---+ '---' '---' +.---. +---+ .---. +| D | | G | | H | +'---' +---+ '---' +0123456789012345678901232 + +Not Supported: + .---+---+---. ++---+ .-. .-. 0 | | a | | b | +| A | | A | | B | 1 +++ +--++-+-++--+ ++---+ '-' '-' 2 | | | | | | | ++---+ .-. .-. 3 +-+ +--++-+-++--+ +| C | | C | | D | 4 | | | | | | ++---+ '-' '-' 5 +++ '---+---+---' +0123434567890123456 | diff --git a/examples/small-nodes.svg b/examples/small-nodes.svg index 3ffda2d..6d2cadc 100644 --- a/examples/small-nodes.svg +++ b/examples/small-nodes.svg @@ -5,6 +5,7 @@ svg { } @media (prefers-color-scheme: dark) { svg { + color-scheme: dark; /* ask the browser for a dark background */ color: #FFFFFF; } } diff --git a/examples/small-shapes.svg b/examples/small-shapes.svg index ef37944..9a6fe29 100644 --- a/examples/small-shapes.svg +++ b/examples/small-shapes.svg @@ -5,6 +5,7 @@ svg { } @media (prefers-color-scheme: dark) { svg { + color-scheme: dark; /* ask the browser for a dark background */ color: #FFFFFF; } } diff --git a/examples/standard-width.svg b/examples/standard-width.svg new file mode 100644 index 0000000..4fbfc23 --- /dev/null +++ b/examples/standard-width.svg @@ -0,0 +1,1987 @@ + + + + + + + + + + + + + + + +U +N +I +C +O +D +E +c +h +a +r +a +c +t +e +r +s +X +X +X +X +F +o +l +d +a +l +l +t +h +i +s +i +n +t +o +e +x +a +m +p +l +e +s +/ +u +n +i +c +o +d +e +. +t +x +t +j +o +i +n +t +s +: +* +o +r +e +s +e +r +v +e +d +: +v +^ +) +( +o +r +d +i +n +a +r +y +, +A +S +C +I +I +: +a +b +c +d +e +f +g +h +i +j +k +l +m +n +o +p +q +r +s +t +u +v +w +x +y +z +A +B +C +D +E +F +G +H +I +J +K +L +M +N +O +P +Q +R +S +T +U +V +W +X +Y +Z +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +0 +1 +2 +3 +4 +5 +o +r +d +i +n +a +r +y +, +U +n +i +c +o +d +e +: + + + + +· +¤ +¨ +´ +« +» +¯ +  +¦ +­ +× +÷ +ø +Ø +± +¡ + + + + + + + + + + + +B +O +X +D +R +A +W +I +N +G +S +L +I +G +H +T +. +. +. + + + + + + + + + + + + + + + + + + + + + + +B +O +X +D +R +A +W +I +N +G +S +L +I +G +H +T +D +O +U +B +L +E +. +. +. + + + + + + + + + + + + + + + + + + + + + + +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +0 +N +o +n +- +s +t +a +n +d +a +r +d +d +i +m +e +n +s +i +o +n +s +i +n +f +o +n +t +s +: +L +i +b +e +r +a +t +i +o +n +M +o +n +o +N +o +t +o +M +o +n +o +R +e +g +u +l +a +r + + + + + + + + + + + +¹ +² +³ + + + + + + +α +β +γ +δ +ε +ζ +η +θ +ι +κ +λ +μ +ν +ξ +ο +π +ρ +ς +σ +τ +υ +φ +χ +ψ +ω +N +o +n +- +s +t +a +n +d +a +r +d +d +i +m +e +n +s +i +o +n +s +i +n +f +o +n +t +s +: +D +e +j +a +V +u +S +a +n +s +M +o +n +o +F +r +e +e +M +o +n +o +U +b +u +n +t +u +M +o +n +o +M +o +n +o +S +p +a +c +e + + + + + + + + + + + + + + + + + + + + + + +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +0 +1 +2 +3 +4 +5 +N +o +n +- +s +t +a +n +d +a +r +d +w +e +i +g +h +t +u +n +u +s +a +b +l +e +? + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +0 +A +L +T +E +R +N +A +T +I +V +E +T +O +O +L +S +# +# +# +N +o +n +- +g +r +a +p +h +i +c +a +l +A +S +C +I +I +s +o +u +r +c +e +: +M +e +r +m +a +i +d +, +P +i +k +c +h +r +. +. +. +# +# +# +G +r +a +p +h +i +c +a +l +A +S +C +I +I +s +o +u +r +c +e +: +A +s +c +i +i +f +l +o +w +a +n +d +T +e +x +t +i +k +U +n +l +i +k +e +G +o +a +t +, +A +s +c +i +i +f +l +o +w +a +n +d +T +e +x +t +i +k +o +f +f +e +r +o +n +l +i +n +e +g +r +a +p +h +i +c +a +l +e +d +i +t +o +r +s +. +D +i +a +g +r +a +m +s +a +r +e +e +x +p +o +r +t +e +d +f +r +o +m +t +h +e +b +r +o +w +s +e +r +s +e +s +s +i +o +n +a +s +g +r +a +p +h +i +c +a +l +U +N +I +C +O +D +E +o +r +A +S +C +I +I +. +F +o +l +l +o +w +- +o +n +m +a +i +n +t +e +n +a +n +c +e +o +f +t +h +e +d +i +a +g +r +a +m +s +o +f +c +o +u +r +s +e +r +e +q +u +i +r +e +s +i +m +p +o +r +t +f +r +o +m +a +p +r +o +j +e +c +t +' +s +c +o +d +e +/ +d +o +c +a +r +c +h +i +v +e +. +A +s +c +i +i +f +l +o +w +a +c +c +o +m +p +l +i +s +h +e +s +t +h +i +s +b +y +C +t +l +- +V +" +p +a +s +t +e +" +. +T +e +x +t +i +k +h +o +w +e +v +e +r +h +a +s +n +o +i +m +p +o +r +t +m +e +t +h +o +d +. +( +h +t +t +p +s +: +/ +/ +g +i +t +h +u +b +. +c +o +m +/ +a +s +t +a +s +h +o +v +/ +t +i +x +i +/ +i +s +s +u +e +s +/ +1 +5 +) +G +o +a +t +b +u +t +n +o +t +A +s +c +i +i +f +l +o +w +n +o +r +T +e +x +t +i +k +c +o +n +t +a +i +n +s +u +p +p +o +r +t +f +o +r +: +1 +. +R +e +n +d +e +r +i +n +g +t +o +a +s +m +o +o +t +h +e +d +S +V +G +o +u +t +p +u +t +. +2 +. +D +i +a +g +o +n +a +l +l +i +n +e +s +. +3 +. +R +o +u +n +d +e +d +c +o +r +n +e +r +s +. +A +s +c +i +i +f +l +o +w +. +c +o +m +( +b +u +t +n +o +t +G +o +a +t +) +e +x +p +o +r +t +s +d +r +a +w +n +l +i +n +e +s +a +s +t +h +e +g +r +a +p +h +i +c +a +l +U +n +i +c +o +d +e +c +h +a +r +a +c +t +e +r +s +B +O +X +D +R +A +W +I +N +G +S +L +I +G +H +T +. +. +. +T +h +e +s +e +h +a +v +e +w +i +d +t +h +s +e +q +u +a +l +t +o +t +h +o +s +e +o +f +s +i +m +p +l +e +A +S +C +I +I +c +h +a +r +a +c +t +e +r +s +i +n +t +h +e +s +t +a +n +d +a +r +d +U +n +i +x +s +y +s +t +e +m +f +o +n +t +s +. +- +h +t +t +p +s +: +/ +/ +w +w +w +. +f +r +e +e +d +e +s +k +t +o +p +. +o +r +g +/ +w +i +k +i +/ +S +o +f +t +w +a +r +e +/ +f +o +n +t +c +o +n +f +i +g +/ +- +$ +a +p +t +s +h +o +w +f +o +n +t +c +o +n +f +i +g +U +n +f +o +r +t +u +n +a +t +e +l +y +, +A +s +c +i +i +f +l +o +w +e +x +p +o +r +t +s +c +e +r +t +a +i +n +a +r +r +o +w +h +e +a +d +s +a +s +U +n +i +c +o +d +e +c +h +a +r +a +c +t +e +r +s +e +. +g +. +" +B +L +A +C +K +U +P +- +P +O +I +N +T +I +N +G +T +R +I +A +N +G +L +E +" +h +a +v +i +n +g +n +o +n +- +s +t +a +n +d +a +r +d +w +i +d +t +h +i +n +t +h +e +p +o +p +u +l +a +r +G +N +U +/ +L +i +n +u +x +s +y +s +t +e +m +f +o +n +t +" +U +b +u +n +t +u +M +o +n +o +R +e +g +u +l +a +r +" +. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +s +d +o +k +p +o +a +s +j +k +f +p +o + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +0 +" +B +O +X +D +R +A +W +I +N +G +S +L +I +G +H +T +D +O +U +B +L +E +. +. +. +" +a +l +s +o +h +a +v +e +s +t +a +n +d +a +r +d +w +i +d +t +h +s +( +n +o +t +u +s +e +d +b +y +A +s +c +i +i +f +l +o +w +) +. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +T +e +x +t +i +k +. +c +o +m +h +a +s +m +o +r +e +l +i +m +i +t +e +d +d +r +a +w +i +n +g +c +h +a +r +a +c +t +e +r +s +, +b +u +t +d +o +e +s +m +a +i +n +t +a +i +n +m +u +l +t +i +- +c +e +l +l +g +e +o +m +e +t +r +y +s +t +r +u +c +t +u +r +e +w +i +t +h +i +n +i +t +s +e +d +i +t +o +r +. + + diff --git a/examples/standard-width.txt b/examples/standard-width.txt new file mode 100644 index 0000000..5c982be --- /dev/null +++ b/examples/standard-width.txt @@ -0,0 +1,106 @@ +UNICODE characters XXXX Fold all this into examples/unicode.txt +--- + +joints: + .'+*o + +reserved: + -_ |v^><*+/\)( + +ordinary, ASCII: +abcdefghijklmnopqrstuvwxyz +ABCDEFGHIJKLMNOPQRSTUVWXYZ +0123456789012345 + +ordinary, Unicode: + +┌─┬┐·¤¨´«»¯ ¦­ +×÷øØ ±¡ +┘┘┘┘┘┘┘┘┘┘┘ BOX DRAWINGS LIGHT ... +│││││││││││ +║║║║║║║║║║║ BOX DRAWINGS LIGHT DOUBLE ... +╚╚╚╚╚╚╚╚╚╚╚ +═══════════ +01234567890 + + +Non-standard dimensions in fonts: + Liberation Mono + Noto Mono Regular +₀₁₂₃₄₅₆₇₈₉ +⁰¹²³⁴⁵⁶⁷⁸⁹ +αβγδεζηθικλμνξοπρςστυφχψω + + +Non-standard dimensions in fonts: + DejaVu Sans Mono + FreeMono + Ubuntu Mono + MonoSpace +⎔ +⬣ +✹ +╱ +╲╲╲╲╲╲╲╲╲ +╳╳╳╳╳╳╳╳╳ +0123456789012345 + +Non-standard weight -- unusable? +╴╴╴╴╴╴╴╴╴╴ +╶╶╶╶╶╶╶╶╶╶ +╵╵╵╵╵╵╵╵╵╵ +╱╱╱╱╱╱╱╱╱╱ +01234567890 + + +ALTERNATIVE TOOLS +--- +### Non-graphical ASCII source: Mermaid, Pikchr ... + +### Graphical ASCII source: Asciiflow and Textik +Unlike Goat, Asciiflow and Textik offer online graphical editors. +Diagrams are exported from the browser session as graphical UNICODE or ASCII. + +Follow-on maintenance of the diagrams of course requires import from a project's code/doc archive. +Asciiflow accomplishes this by Ctl-V "paste". +Textik however has no import method. (https://github.com/astashov/tixi/issues/15) + +Goat but not Asciiflow nor Textik contain support for: + 1. Rendering to a smoothed SVG output. + 2. Diagonal lines. + 3. Rounded corners. + +Asciiflow.com (but not Goat) exports drawn lines as the graphical Unicode +characters BOX DRAWINGS LIGHT ... + +These have widths equal to those of simple ASCII characters in the standard Unix system fonts. + - https://www.freedesktop.org/wiki/Software/fontconfig/ + - $ apt show fontconfig + +Unfortunately, Asciiflow exports certain arrowheads as Unicode characters e.g. "BLACK UP-POINTING + TRIANGLE" having non-standard width in the popular GNU/Linux system font "Ubuntu Mono Regular". + + ┌───────────────────────► + │ + │ + ┌─────┼──────────────┐ ▲ + │ │ │ │ + └─────┼──────────────┘ │ + │ │ + │ │ +┌──────────────────┐ │ +│ │ │ +│ sdokpoasjkfpo ├─────────────────────────────────────────┘ +└──────────────────┘ + +▲▲▲▲▲▲▲ +01234567890 + +"BOX DRAWINGS LIGHT DOUBLE ..." also have standard widths (not used by Asciiflow). + +║║║║║║║║║║║ +╚╚╚╚╚╚╚╚╚╚╚ +═══════════ + +Textik.com has more limited drawing characters, but does maintain multi-cell +geometry structure within its editor. diff --git a/examples/tiny-grids-irregular.svg b/examples/tiny-grids-irregular.svg new file mode 100644 index 0000000..cf77a4e --- /dev/null +++ b/examples/tiny-grids-irregular.svg @@ -0,0 +1,125 @@ + + + + + +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +0 +1 +2 +3 +4 +5 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +0 +1 +2 +3 +4 +5 + + diff --git a/examples/tiny-grids-irregular.txt b/examples/tiny-grids-irregular.txt new file mode 100644 index 0000000..15e47da --- /dev/null +++ b/examples/tiny-grids-irregular.txt @@ -0,0 +1,19 @@ +0123456789012345 + ▉▉ ▉▉ ▉▉ | + ▉▉ ▉▉ | + ▉▉ ▉▉ ▉▉ | + ▉▉ ▉▉ | + ▉▉ ▉▉ ▉▉ | + | + ⬢ ⬡ ⬡ | + ⬢ ⬢ ⬡ ⬡ | + ⬢ ⬢ ⬢ ⬡ ⬡ | + ⬡ ⬡ ⬡ ⬡ | + ⬡ ⬡ ⬡ | + | + ⁚⁚⁚⁚⁚⁚⁚⁚⁚⁚ | + ⁚⁚⁚⁚⁚⁚⁚⁚⁚⁚ | + ⁚⁚⁚⁚⁚⁚⁚⁚⁚⁚ | + ⁚⁚⁚⁚⁚⁚⁚⁚⁚⁚ | + ⁚⁚⁚⁚⁚⁚⁚⁚⁚⁚ | +0123456789012345 diff --git a/examples/tiny-grids.svg b/examples/tiny-grids.svg index db3ef85..78cc486 100644 --- a/examples/tiny-grids.svg +++ b/examples/tiny-grids.svg @@ -5,6 +5,7 @@ svg { } @media (prefers-color-scheme: dark) { svg { + color-scheme: dark; /* ask the browser for a dark background */ color: #FFFFFF; } } diff --git a/examples/trees.svg b/examples/trees.svg index f1d8101..c5c3a78 100644 --- a/examples/trees.svg +++ b/examples/trees.svg @@ -5,6 +5,7 @@ svg { } @media (prefers-color-scheme: dark) { svg { + color-scheme: dark; /* ask the browser for a dark background */ color: #FFFFFF; } } diff --git a/examples/unicode.svg b/examples/unicode.svg index 360b84f..a9619c4 100644 --- a/examples/unicode.svg +++ b/examples/unicode.svg @@ -5,6 +5,7 @@ svg { } @media (prefers-color-scheme: dark) { svg { + color-scheme: dark; /* ask the browser for a dark background */ color: #FFFFFF; } } diff --git a/examples_test.go b/examples_test.go index daec3cc..8131225 100644 --- a/examples_test.go +++ b/examples_test.go @@ -2,32 +2,17 @@ package goat import ( "bytes" - "flag" "io" - "io/ioutil" "os" "path/filepath" - "strings" "testing" - - qt "github.com/frankban/quicktest" -) - -var ( - write = flag.Bool("write", false, "write examples to disk") - svgColorLightScheme = flag.String("svg-color-light-scheme", "#000000", - `See help for cmd/goat`) - svgColorDarkScheme = flag.String("svg-color-dark-scheme", "#FFFFFF", - `See help for cmd/goat`) ) // XX TXT source file suite is limited to a single file -- "circuits.txt" -func TestExamplesStableOutput(t *testing.T) { - c := qt.New(t) - +func TestExampleStableOutput(t *testing.T) { var previous string for i := 0; i < 3; i++ { - in, err := os.Open(filepath.Join(basePath, "circuits.txt")) + in, err := os.Open(filepath.Join(examplesDir, "circuits.txt")) if err != nil { t.Fatal(err) } @@ -35,107 +20,18 @@ func TestExamplesStableOutput(t *testing.T) { BuildAndWriteSVG(in, &out, "black", "white") in.Close() if i > 0 && previous != out.String() { - c.Fail() + t.FailNow() } previous = out.String() } } -func TestExamples(t *testing.T) { - filenames, err := filepath.Glob(filepath.Join(basePath, "*.txt")) - if err != nil { - t.Fatal(err) - } - - var buff *bytes.Buffer - - for _, name := range filenames { - in := getIn(name) - if testing.Verbose() { - t.Logf("\tprocessing %s\n", name) - } - var out io.WriteCloser - if *write { - out = getOut(name) - } else { - if buff == nil { - buff = &bytes.Buffer{} - } else { - buff.Reset() - } - out = struct { - io.Writer - io.Closer - }{ - buff, - io.NopCloser(nil), - } - } - - BuildAndWriteSVG(in, out, *svgColorLightScheme, *svgColorDarkScheme) - - in.Close() - out.Close() - - if buff != nil { - golden, err := getOutString(name) - if err != nil { - t.Log(err) - } - if buff.String() != golden { - // XX Skip this if the modification timestamp of the .txt file - // source is fresher than the .svg? - t.Log(buff.Len(), len(golden)) - t.Logf("Content mismatch for %s", toSVGFilename(name)) - t.Logf("%s %s:\n\t%s\nConsider:\n\t%s", - "Option -write not set, and Error reading", - name, - err.Error(), - "$ go test -run TestExamples -v -args -write") - t.FailNow() - } - in.Close() - out.Close() - } - } -} - func BenchmarkComplicated(b *testing.B) { in := getIn(filepath.FromSlash("examples/complicated.txt")) b.ResetTimer() for i := 0; i < b.N; i++ { BuildAndWriteSVG(in, io.Discard, "black", "white") } -} - -const basePath string = "examples" - -func getIn(filename string) io.ReadCloser { - in, err := os.Open(filename) - if err != nil { - panic(err) - } - return in -} - -func getOut(filename string) io.WriteCloser { - out, err := os.Create(toSVGFilename(filename)) - if err != nil { - panic(err) - } - return out -} - -func getOutString(filename string) (string, error) { - b, err := ioutil.ReadFile(toSVGFilename(filename)) - if err != nil { - return "", err - } - b = bytes.ReplaceAll(b, []byte("\r\n"), []byte("\n")) - return string(b), nil -} - -func toSVGFilename(filename string) string { - return strings.TrimSuffix(filename, filepath.Ext(filename)) + ".svg" + in.Close() } diff --git a/go.mod b/go.mod index f9a07e8..6ede171 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/blampe/goat -go 1.17 +go 1.21 require ( github.com/frankban/quicktest v1.14.2 diff --git a/goat.svg b/goat.svg index ee43859..7fdc72a 100644 --- a/goat.svg +++ b/goat.svg @@ -5,6 +5,7 @@ svg { } @media (prefers-color-scheme: dark) { svg { + color-scheme: dark; /* ask the browser for a dark background */ color: #EEF; } } diff --git a/pre-push.sh b/pre-push.sh index d865ec3..384709a 100755 --- a/pre-push.sh +++ b/pre-push.sh @@ -12,14 +12,14 @@ set -x usage () { set +x printf "%s\n\n" "$*" - printf "usage: %s [-w]\n" ${0##*/} - printf "\t%s\t%s\n" "" - printf "\t%s\t%s\n" "$*" + printf "usage: %s [-w]\n" "${0##*/}" + printf "\t%s\n" "" + printf "\t%s\n" "$*" exit 1 } TEST_ARGS= -while getopts hg:iw flag +while getopts h:w flag do case $flag in h) usage "";; @@ -36,7 +36,7 @@ GITHUB_REPOSITORY_OWNER=$USER CURRENT_BRANCH_NAME=$(git-branch --show-current) # If the current branch name contains the GitHub username of the owner of the upstream repo, # assume the intention is to prepare and push a pull request. -if [ $(expr $CURRENT_BRANCH_NAME : ".*$UPSTREAM_OWNER") != 0 ] +if [ $(expr "$CURRENT_BRANCH_NAME" : ".*$UPSTREAM_OWNER") != 0 ] then GITHUB_REPOSITORY_OWNER=$UPSTREAM_OWNER fi @@ -87,3 +87,5 @@ go run ./cmd/goat README.md $(git-ls-files examples | tac) + +printf "\nTo install in local GOPATH:\n\t%s\n" "go install ./cmd/goat" diff --git a/svg.go b/svg.go index 7a416b3..4c89474 100644 --- a/svg.go +++ b/svg.go @@ -21,6 +21,7 @@ svg { } @media (prefers-color-scheme: dark) { svg { + color-scheme: dark; /* ask the browser for a dark background */ color: %s; } } @@ -102,10 +103,13 @@ func (l Line) draw(out io.Writer) { // Half steps switch l.chop { + case NONE: case N: stop.Y -= 8 case S: start.Y += 8 + default: + panic("impossible 'chop' orientation") } } diff --git a/trees.mid-blue.svg b/trees.mid-blue.svg index a98875e..118fef6 100644 --- a/trees.mid-blue.svg +++ b/trees.mid-blue.svg @@ -5,6 +5,7 @@ svg { } @media (prefers-color-scheme: dark) { svg { + color-scheme: dark; /* ask the browser for a dark background */ color: #2F81F7; } }