1package html
2
3import (
4 "fmt"
5 "math"
6 "unicode/utf8"
7
8 "github.com/bnema/vev-vt/core"
9)
10
11type Renderer struct {
12 limits Limits
13 generation uint64
14 pending *transaction
15 committed core.Frame
16 // spare is a frame page no update or transaction references. Prepare
17 // copies a core.Frame source into it; Commit and Abort hand a page back.
18 spare core.Frame
19 cursor Cursor
20 initialized bool
21 // Encoder scratch reused across updates; never referenced by an Update.
22 ends []int
23 text []byte
24 rowStarts []int
25}
26
27// CellSource is the read-only semantic grid consumed by HTML rendering.
28type CellSource = core.CellSource
29
30type transactionState uint8
31
32const (
33 transactionPending transactionState = iota
34 transactionCommitted
35 transactionAborted
36)
37
38type transaction struct {
39 renderer *Renderer
40 generation uint64
41 state transactionState
42 update Update
43 json []byte
44 frame core.Frame
45 cursor Cursor
46}
47
48// PreparedDraw owns one immutable update and its speculative renderer state.
49type PreparedDraw struct {
50 tx *transaction
51}
52
53func New(options Options) (*Renderer, error) {
54 limits, err := normalizeLimits(options.Limits)
55 if err != nil {
56 return nil, err
57 }
58 return &Renderer{limits: limits, generation: 1}, nil
59}
60
61// Prepare validates and encodes a speculative update. The renderer advances
62// only after Commit. Abort preserves the previous committed shadow.
63func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool, cursor Cursor) (*PreparedDraw, error) {
64 if r == nil {
65 return nil, fmt.Errorf("html: nil renderer")
66 }
67 if r.pending != nil {
68 return nil, ErrPendingDraw
69 }
70 frame, err := r.materializeCellSource(source)
71 if err != nil {
72 return nil, err
73 }
74 update, encoded, err := r.prepareUpdate(frame, damage, reset, &cursor)
75 if err != nil {
76 r.spare = frame
77 return nil, err
78 }
79
80 tx := &transaction{
81 renderer: r,
82 generation: r.generation,
83 state: transactionPending,
84 update: update,
85 json: encoded,
86 frame: frame,
87 cursor: cursor,
88 }
89 r.pending = tx
90 return &PreparedDraw{tx: tx}, nil
91}
92
93func (r *Renderer) prepareUpdate(frame core.Frame, damage []core.Damage, reset bool, cursor *Cursor) (Update, []byte, error) {
94 scratch, err := r.validateFrame(frame)
95 if err != nil {
96 return Update{}, nil, err
97 }
98 *cursor = normalizeWrapPendingCursor(*cursor, frame.Width)
99 if err := validateCursor(*cursor, frame.Width, frame.Height); err != nil {
100 return Update{}, nil, err
101 }
102 snapshot := reset || !r.initialized || r.committed.Width != frame.Width || r.committed.Height != frame.Height || damageRequiresSnapshot(damage, frame.Width, frame.Height)
103 update, err := r.buildUpdate(frame, scratch, snapshot, *cursor)
104 if err != nil {
105 return Update{}, nil, err
106 }
107 encoded, err := encodeBoundedUpdate(update, r.limits.MaxGeneratedBytes)
108 if err != nil {
109 return Update{}, nil, err
110 }
111 return update, encoded, nil
112}
113
114// validateFrame checks limits and cell invariants. It returns a one-row
115// scratch buffer that the caller may reuse for the rest of this Prepare.
116func (r *Renderer) validateFrame(frame core.Frame) ([]core.Cell, error) {
117 if frame.Width <= 0 || frame.Height <= 0 {
118 return nil, fmt.Errorf("html: invalid frame size %dx%d", frame.Width, frame.Height)
119 }
120 if frame.Width > math.MaxInt/frame.Height {
121 return nil, fmt.Errorf("%w: frame cell count overflows int", ErrLimitExceeded)
122 }
123 cells := frame.Width * frame.Height
124 if cells > r.limits.MaxCells {
125 return nil, fmt.Errorf("%w: frame has %d cells, limit is %d", ErrLimitExceeded, cells, r.limits.MaxCells)
126 }
127 if frame.Height > r.limits.MaxRowsPerUpdate {
128 return nil, fmt.Errorf("%w: frame has %d rows, limit is %d", ErrLimitExceeded, frame.Height, r.limits.MaxRowsPerUpdate)
129 }
130 if err := frame.Validate(); err != nil {
131 return nil, fmt.Errorf("html: validate frame: %w", err)
132 }
133 scratch := make([]core.Cell, frame.Width)
134 for y := range frame.Height {
135 row := readRow(frame, y, scratch)
136 for x := 0; x < frame.Width; x++ {
137 cell := row[x]
138 if err := validateCoreStyle(cell.Style); err != nil {
139 return nil, fmt.Errorf("html: cell (%d,%d): %w", x, y, err)
140 }
141 if cell.Continuation {
142 if cell.Rune != 0 {
143 return nil, fmt.Errorf("html: cell (%d,%d): wide continuation contains a rune", x, y)
144 }
145 if x == 0 || row[x-1].Continuation || core.RuneWidth(row[x-1].Rune) != 2 {
146 return nil, fmt.Errorf("html: cell (%d,%d): orphan wide continuation", x, y)
147 }
148 if !cell.Style.Equal(row[x-1].Style) {
149 return nil, fmt.Errorf("html: cell (%d,%d): wide continuation style differs from its head", x, y)
150 }
151 continue
152 }
153 if cell.Rune == 0 {
154 continue
155 }
156 if !utf8.ValidRune(cell.Rune) {
157 return nil, fmt.Errorf("html: cell (%d,%d): invalid Unicode scalar", x, y)
158 }
159 width := core.RuneWidth(cell.Rune)
160 switch width {
161 case 1:
162 case 2:
163 if x+1 >= frame.Width || !row[x+1].Continuation {
164 return nil, fmt.Errorf("html: cell (%d,%d): wide rune lacks continuation", x, y)
165 }
166 case 0:
167 return nil, fmt.Errorf("html: cell (%d,%d): unsupported zero-width rune", x, y)
168 default:
169 return nil, fmt.Errorf("html: cell (%d,%d): unsupported rune width %d", x, y, width)
170 }
171 }
172 }
173 return scratch, nil
174}
175
176// materializeCellSource reads a source into an owned frame. A core.Frame is
177// copied into the renderer's spare page; any other source is copied cell by
178// cell.
179func (r *Renderer) materializeCellSource(source CellSource) (core.Frame, error) {
180 if source == nil {
181 return core.Frame{}, fmt.Errorf("html: nil cell source")
182 }
183 switch frame := source.(type) {
184 case core.Frame:
185 if err := frame.Validate(); err != nil {
186 return core.Frame{}, fmt.Errorf("html: validate frame: %w", err)
187 }
188 return r.copyFrame(frame), nil
189 case *core.Frame:
190 if frame == nil {
191 return core.Frame{}, fmt.Errorf("html: nil cell source")
192 }
193 if err := frame.Validate(); err != nil {
194 return core.Frame{}, fmt.Errorf("html: validate frame: %w", err)
195 }
196 return r.copyFrame(*frame), nil
197 }
198 width, height := source.Columns(), source.Rows()
199 if width <= 0 || height <= 0 {
200 return core.Frame{}, fmt.Errorf("html: invalid cell source size %dx%d", width, height)
201 }
202 clone := core.NewFrame(width, height)
203 for y := range height {
204 for x := range width {
205 clone.Set(x, y, source.Cell(x, y))
206 }
207 }
208 return clone, nil
209}
210
211// copyFrame copies a validated frame into the spare page, which the returned
212// frame then owns. CopyFrom always succeeds here: src passed Validate.
213func (r *Renderer) copyFrame(src core.Frame) core.Frame {
214 frame := r.spare
215 r.spare = core.Frame{}
216 frame.CopyFrom(src)
217 return frame
218}
219
220// maxRetainedRows bounds the row count whose encoder scratch the renderer
221// keeps between updates.
222const maxRetainedRows = 80
223
224// normalizeWrapPendingCursor maps the deferred-wrap one-past-end column
225// produced after writing the last column with autowrap enabled onto the
226// last visible column. It leaves every in-bounds cursor untouched.
227func normalizeWrapPendingCursor(cursor Cursor, width int) Cursor {
228 if cursor.Column == width && cursor.Row >= 0 {
229 cursor.Column = width - 1
230 }
231 return cursor
232}
233
234func validateCursor(cursor Cursor, width, height int) error {
235 if cursor.Row < 0 || cursor.Row >= height || cursor.Column < 0 || cursor.Column >= width {
236 return fmt.Errorf("html: cursor (%d,%d) outside %dx%d frame", cursor.Column, cursor.Row, width, height)
237 }
238 if cursor.Style > 6 {
239 return fmt.Errorf("html: invalid cursor style %d", cursor.Style)
240 }
241 if !cursor.StyleSet && cursor.Style != 0 {
242 return fmt.Errorf("html: cursor style must be zero when unset")
243 }
244 return nil
245}
246
247func damageRequiresSnapshot(damage []core.Damage, width, height int) bool {
248 for _, item := range damage {
249 switch item.Kind {
250 case core.DamageText, core.DamageClear:
251 if item.X < 0 || item.Y < 0 || item.Width <= 0 || item.Height <= 0 || item.X > width-item.Width || item.Y > height-item.Height {
252 return true
253 }
254 case core.DamageScrollUp, core.DamageFullRedraw:
255 return true
256 default:
257 return true
258 }
259 }
260 return false
261}
262
263// readRow copies logical row y into buf and returns the filled prefix. It is
264// the allocation-free equivalent of core.Frame.Row.
265func readRow(frame core.Frame, y int, buf []core.Cell) []core.Cell {
266 row := buf[:frame.Width]
267 for x := range row {
268 row[x] = frame.Cell(x, y)
269 }
270 return row
271}
272
273func (r *Renderer) buildUpdate(frame core.Frame, scratch []core.Cell, snapshot bool, cursor Cursor) (Update, error) {
274 update := Update{
275 SchemaVersion: UpdateSchemaVersion,
276 Width: frame.Width,
277 Height: frame.Height,
278 Snapshot: snapshot,
279 Rows: make([]RowUpdate, 0),
280 Styles: make([]Style, 0),
281 Cursor: cursor,
282 }
283 styleIDs := make(map[Style]int)
284 // Every row appends its runs to cells and their text to text; ends[i]
285 // is the text offset after cells[i]. Rows and texts are sliced out once
286 // at the end, so an update costs one cell slice and one string.
287 // cells is owned by the update and sized for one full row (an
288 // incremental update usually changes one or a few rows). The other
289 // buffers are renderer scratch: the text is copied into one string.
290 cells := make([]CellUpdate, 0, frame.Width)
291 ends, text, rowStarts := r.ends[:0], r.text[:0], r.rowStarts[:0]
292 if cap(ends) < frame.Width {
293 ends = make([]int, 0, frame.Width)
294 text = make([]byte, 0, frame.Width)
295 }
296 if cap(rowStarts) < min(frame.Height, maxRetainedRows) {
297 rowStarts = make([]int, 0, min(frame.Height, maxRetainedRows))
298 }
299 defer func() {
300 // Keep scratch for frames up to the common 240x80 size only, so one
301 // huge snapshot does not pin its buffers for the renderer lifetime.
302 const maxRetainedCells = 240 * maxRetainedRows
303 if cap(ends) <= maxRetainedCells && cap(text) <= 4*maxRetainedCells && cap(rowStarts) <= maxRetainedRows {
304 r.ends, r.text, r.rowStarts = ends, text, rowStarts
305 }
306 }()
307 for y := range frame.Height {
308 if !snapshot && core.RowsEqualAt(frame, y, r.committed, y) {
309 continue
310 }
311 row := readRow(frame, y, scratch)
312 if len(update.Rows) >= r.limits.MaxRowsPerUpdate {
313 return Update{}, fmt.Errorf("%w: update rows exceed limit %d", ErrLimitExceeded, r.limits.MaxRowsPerUpdate)
314 }
315 rowStarts = append(rowStarts, len(cells))
316 update.Rows = append(update.Rows, RowUpdate{Row: y})
317 var err error
318 cells, ends, text, err = encodeRow(row, cells, ends, text, &update.Styles, styleIDs, r.limits.MaxStyles)
319 if err != nil {
320 return Update{}, err
321 }
322 }
323 all := string(text)
324 offset := 0
325 for i := range cells {
326 cells[i].Text = all[offset:ends[i]]
327 offset = ends[i]
328 }
329 for i := range update.Rows {
330 next := len(cells)
331 if i+1 < len(rowStarts) {
332 next = rowStarts[i+1]
333 }
334 update.Rows[i].Cells = cells[rowStarts[i]:next:next]
335 }
336 return update, nil
337}
338
339// encodeBoundedUpdate encodes one update and enforces MaxGeneratedBytes on
340// the exact encoded size. The previous conservative estimate could reject
341// compact updates well below the configured limit.
342func encodeBoundedUpdate(update Update, limit int) ([]byte, error) {
343 // The hint never exceeds limit, so an oversized update cannot reserve its
344 // full estimate before the limit rejects it. limit+1 would overflow for
345 // math.MaxInt.
346 encoded, err := appendUpdateJSON(make([]byte, 0, min(estimateUpdateJSONSize(update), limit)), update)
347 if err != nil {
348 return nil, fmt.Errorf("html: encode update: %w", err)
349 }
350 if len(encoded) > limit {
351 return nil, fmt.Errorf("%w: generated update is %d bytes, limit is %d", ErrLimitExceeded, len(encoded), limit)
352 }
353 return encoded, nil
354}
355
356// runText returns the single-column printable ASCII text of cell, treating a
357// blank cell as a space, or false when the cell cannot join a text run.
358func runText(cell core.Cell) (byte, bool) {
359 switch {
360 case cell.Continuation:
361 return 0, false
362 case cell.Rune == 0:
363 return ' ', true
364 case cell.Rune >= 0x20 && cell.Rune < 0x7f:
365 return byte(cell.Rune), true
366 default:
367 return 0, false
368 }
369}
370
371// encodeRow appends one CellUpdate per text run of a row to cells, its text
372// to text, and the text end offset to ends. Adjacent printable ASCII or blank
373// cells with equal styles merge into one run whose Width equals its byte
374// length. Every other cell (wide or non-ASCII) stays a single entry, so
375// browsers never rely on a fallback font's advance for column alignment.
376// Text fields are filled by the caller.
377func encodeRow(row []core.Cell, cells []CellUpdate, ends []int, text []byte, styles *[]Style, styleIDs map[Style]int, maxStyles int) ([]CellUpdate, []int, []byte, error) {
378 for x := 0; x < len(row); x++ {
379 cell := row[x]
380 if cell.Continuation {
381 continue
382 }
383 style := styleFromCore(cell.Style)
384 styleID, ok := styleIDs[style]
385 if !ok {
386 if len(*styles) >= maxStyles {
387 return cells, ends, text, fmt.Errorf("%w: update styles exceed limit %d", ErrLimitExceeded, maxStyles)
388 }
389 styleID = len(*styles)
390 styleIDs[style] = styleID
391 *styles = append(*styles, style)
392 }
393 if first, ok := runText(cell); ok {
394 start := x
395 text = append(text, first)
396 for x+1 < len(row) && row[x+1].Style.Equal(cell.Style) {
397 next, ok := runText(row[x+1])
398 if !ok {
399 break
400 }
401 text = append(text, next)
402 x++
403 }
404 cells = append(cells, CellUpdate{Column: start, Width: x - start + 1, Style: styleID})
405 } else {
406 text = utf8.AppendRune(text, cell.Rune)
407 cells = append(cells, CellUpdate{Column: x, Width: core.RuneWidth(cell.Rune), Style: styleID})
408 }
409 ends = append(ends, len(text))
410 }
411 return cells, ends, text, nil
412}
413
414func validateCoreStyle(style core.Style) error {
415 const knownAttrs = core.AttrDim | core.AttrUnderline | core.AttrBlink | core.AttrStrikethrough
416 if style.Attrs & ^knownAttrs != 0 {
417 return fmt.Errorf("unknown style attribute bits %#x", style.Attrs&^knownAttrs)
418 }
419 if style.UnderlineStyle > core.UnderlineDashed {
420 return fmt.Errorf("invalid underline style %d", style.UnderlineStyle)
421 }
422 if !style.HasForegroundRGB && (style.Foreground < -1 || style.Foreground > 255) {
423 return fmt.Errorf("invalid foreground index %d", style.Foreground)
424 }
425 if !style.HasBackgroundRGB && (style.Background < -1 || style.Background > 255) {
426 return fmt.Errorf("invalid background index %d", style.Background)
427 }
428 if !style.HasUnderlineColorRGB && style.HasUnderlineColor && (style.UnderlineColor < 0 || style.UnderlineColor > 255) {
429 return fmt.Errorf("invalid underline color index %d", style.UnderlineColor)
430 }
431 return nil
432}
433
434func styleFromCore(style core.Style) Style {
435 return Style{
436 Bold: style.Bold,
437 Italic: style.Italic,
438 Inverse: style.Inverse,
439 Dim: style.Attrs&core.AttrDim != 0,
440 Blink: style.Attrs&core.AttrBlink != 0,
441 Strikethrough: style.Attrs&core.AttrStrikethrough != 0,
442 Underline: style.Attrs&core.AttrUnderline != 0,
443 UnderlineStyle: style.UnderlineStyle,
444 Foreground: colorFromCore(style.Foreground, style.HasForegroundRGB, style.ForegroundRGB),
445 Background: colorFromCore(style.Background, style.HasBackgroundRGB, style.BackgroundRGB),
446 UnderlineColor: underlineColorFromCore(style),
447 }
448}
449
450// Prepare validates every indexed color before these uint8 conversions.
451func colorFromCore(index int, hasRGB bool, rgb core.RGB) Color {
452 if hasRGB {
453 return Color{Kind: ColorRGB, RGB: RGB{R: rgb.R, G: rgb.G, B: rgb.B}}
454 }
455 if index >= 0 {
456 return Color{Kind: ColorIndexed, Index: uint8(index)}
457 }
458 return Color{Kind: ColorDefault}
459}
460
461func underlineColorFromCore(style core.Style) Color {
462 if style.HasUnderlineColorRGB {
463 return Color{Kind: ColorRGB, RGB: RGB{R: style.UnderlineColorRGB.R, G: style.UnderlineColorRGB.G, B: style.UnderlineColorRGB.B}}
464 }
465 if style.HasUnderlineColor {
466 return Color{Kind: ColorIndexed, Index: uint8(style.UnderlineColor)}
467 }
468 return Color{Kind: ColorDefault}
469}
470
471// Update returns an owned copy that remains valid after later renderer work.
472func (p *PreparedDraw) Update() Update {
473 if p == nil || p.tx == nil {
474 return Update{}
475 }
476 return cloneUpdate(p.tx.update)
477}
478
479// JSON returns an owned canonical JSON representation of Update.
480func (p *PreparedDraw) JSON() []byte {
481 if p == nil || p.tx == nil {
482 return nil
483 }
484 return append([]byte(nil), p.tx.json...)
485}
486
487// Commit atomically advances the renderer shadow exactly once.
488func (p *PreparedDraw) Commit() error {
489 if p == nil || p.tx == nil || p.tx.renderer == nil {
490 return ErrStaleDraw
491 }
492 tx := p.tx
493 if tx.state != transactionPending {
494 return ErrFinalizedDraw
495 }
496 r := tx.renderer
497 if tx.generation != r.generation || r.pending != tx {
498 return ErrStaleDraw
499 }
500 r.spare = r.committed
501 r.committed = tx.frame
502 r.cursor = tx.cursor
503 r.initialized = true
504 r.pending = nil
505 tx.state = transactionCommitted
506 return nil
507}
508
509// Abort finalizes the transaction without advancing the renderer shadow.
510func (p *PreparedDraw) Abort() error {
511 if p == nil || p.tx == nil || p.tx.renderer == nil {
512 return ErrStaleDraw
513 }
514 tx := p.tx
515 if tx.state != transactionPending {
516 return ErrFinalizedDraw
517 }
518 r := tx.renderer
519 if tx.generation != r.generation || r.pending != tx {
520 return ErrStaleDraw
521 }
522 r.spare = tx.frame
523 r.pending = nil
524 tx.state = transactionAborted
525 return nil
526}
527
528// Reset invalidates any prepared draw and clears committed state.
529func (r *Renderer) Reset() {
530 if r == nil {
531 return
532 }
533 r.generation++
534 r.pending = nil
535 r.committed = core.Frame{}
536 r.spare = core.Frame{}
537 r.cursor = Cursor{}
538 r.initialized = false
539}