From f9da63f9279aa1bc6a864d8bf0af3beef5be0e30 Mon Sep 17 00:00:00 2001 From: "Alexandre.DeMasi" Date: Wed, 29 Apr 2026 22:00:56 +0200 Subject: [PATCH 1/3] Add v6 .rm format support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse the v6 tagged-block/CRDT scene tree format used by reMarkable firmware 3.0+, enabling `geta` to export annotations from modern documents. V6 PDF-backed pages use DPI-based coordinate scaling (226→72 DPI) instead of the device-pixel mapping used by v3/v5. Stroke line widths are derived from per-point width data rather than the v3/v5 brush-size formula. Reads the firmware 3.0+ `cPages` content layout with CRDT ordering and PDF page redirection, plus a last-resort pageMap fallback discovered from the .rm filenames in the zip. Co-Authored-By: Claude Opus 4.6 --- annotations/pdf.go | 95 +++++-- archive/file.go | 31 ++- archive/reader.go | 45 +++- encoding/rm/rm.go | 24 +- encoding/rm/unmarshal.go | 12 + encoding/rm/v6.go | 516 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 696 insertions(+), 27 deletions(-) create mode 100644 encoding/rm/v6.go diff --git a/annotations/pdf.go b/annotations/pdf.go index be8f29fb..1b02c708 100644 --- a/annotations/pdf.go +++ b/annotations/pdf.go @@ -4,7 +4,7 @@ import ( "bytes" "errors" "fmt" - + "math" "os" "github.com/juruen/rmapi/archive" @@ -43,8 +43,8 @@ func CreatePdfGenerator(zipName, outputFilePath string, options PdfGeneratorOpti return &PdfGenerator{zipName: zipName, outputFilePath: outputFilePath, options: options} } -func normalized(p1 rm.Point, ratioX float64) (float64, float64) { - return float64(p1.X) * ratioX, float64(p1.Y) * ratioX +func normalized(p1 rm.Point, scale, xShift float64) (float64, float64) { + return float64(p1.X)*scale + xShift, float64(p1.Y) * scale } func (p *PdfGenerator) Generate() error { @@ -92,8 +92,10 @@ func (p *PdfGenerator) Generate() error { for _, pageAnnotations := range zip.Pages { hasContent := pageAnnotations.Data != nil - // do not add a page when there are no annotations - if !p.options.AllPages && !hasContent { + // For PDFs, always include all pages so the full document is + // preserved with annotations overlaid. For notebooks (no source + // PDF), skip blank pages unless -a is given. + if !hasContent && !p.options.AllPages && p.pdfReader == nil { continue } //1 based, redirected page @@ -104,25 +106,33 @@ func (p *PdfGenerator) Generate() error { return err } - ratio := c.Height() / c.Width() - - var scale float64 - if ratio < 1.33 { - scale = c.Width() / DeviceWidth - } else { - scale = c.Height() / DeviceHeight - } if page == nil { log.Error.Fatal("page is null") } - - if err != nil { - return err - } if !hasContent { continue } + var scale float64 + var xShift float64 + isV6Pdf := pageAnnotations.Data.Version == rm.V6 && pageAnnotations.DocPage >= 0 + + if isV6Pdf { + // v6 PDF-backed pages: DPI-based scale (226 DPI device → 72 DPI PDF). + // The v6 parser adds +Width/2 to X so that x=Width/2 is page center; + // xShift compensates so that maps to c.Width()/2 in PDF points. + scale = 72.0 / 226.0 + xShift = c.Width()/2 - float64(rm.Width)/2*scale + } else { + // v3/v5, v6 blank/added pages, or notebooks + ratio := c.Height() / c.Width() + if ratio < 1.33 { + scale = c.Width() / DeviceWidth + } else { + scale = c.Height() / DeviceHeight + } + } + contentCreator := contentstream.NewContentCreator() contentCreator.Add_q() @@ -137,8 +147,8 @@ func (p *PdfGenerator) Generate() error { if line.BrushType == rm.HighlighterV5 { last := len(line.Points) - 1 - x1, y1 := normalized(line.Points[0], scale) - x2, _ := normalized(line.Points[last], scale) + x1, y1 := normalized(line.Points[0], scale, xShift) + x2, _ := normalized(line.Points[last], scale, xShift) // make horizontal lines only, use y1 width := scale * 30 y1 += width / 2 @@ -155,11 +165,25 @@ func (p *PdfGenerator) Generate() error { } else { path := draw.NewPath() for i := 0; i < len(line.Points); i++ { - x1, y1 := normalized(line.Points[i], scale) + x1, y1 := normalized(line.Points[i], scale, xShift) path = path.AppendPoint(draw.NewPoint(x1, c.Height()-y1)) } - contentCreator.Add_w(float64(line.BrushSize*6.0 - 10.8)) + var lineWidth float64 + if pageAnnotations.Data.Version == rm.V6 { + // v6: per-point Width is in device pixels; average and scale to PDF points. + var totalW float64 + for _, pt := range line.Points { + totalW += float64(pt.Width) + } + lineWidth = totalW / float64(len(line.Points)) * scale + } else { + lineWidth = float64(line.BrushSize*6.0 - 10.8) + } + if lineWidth < 0.1 { + lineWidth = 0.1 + } + contentCreator.Add_w(lineWidth) switch line.BrushColor { case rm.Black: @@ -168,6 +192,10 @@ func (p *PdfGenerator) Generate() error { contentCreator.Add_rg(0.0, 0.0, 0.0) case rm.Grey: contentCreator.Add_rg(0.8, 0.8, 0.8) + case rm.Blue: + contentCreator.Add_rg(0.0, 0.38, 0.8) + case rm.Red: + contentCreator.Add_rg(0.85, 0.03, 0.03) } //TODO: use bezier @@ -177,6 +205,31 @@ func (p *PdfGenerator) Generate() error { } } } + for _, hl := range pageAnnotations.Data.Highlights { + for _, rect := range hl.Rects { + // Highlight rects don't have the +Width/2 X offset that strokes do; + // for v6 PDF pages, x=0 is at the horizontal center of the page. + hlXOff := xShift + if isV6Pdf { + hlXOff = c.Width() / 2 + } + x1 := rect.X*scale + hlXOff + y1 := c.Height() - rect.Y*scale + x2 := (rect.X+rect.W)*scale + hlXOff + y2 := c.Height() - (rect.Y+rect.H)*scale + + lineDef := annotator.LineAnnotationDef{X1: x1, Y1: y2, X2: x2, Y2: y1} + lineDef.LineColor = pdf.NewPdfColorDeviceRGB(1.0, 1.0, 0.0) + lineDef.Opacity = 0.3 + lineDef.LineWidth = math.Abs(y1 - y2) + ann, err := annotator.CreateLineAnnotation(lineDef) + if err != nil { + continue + } + page.AddAnnotation(ann) + } + } + contentCreator.Add_Q() drawingOperations := contentCreator.Operations().String() pageContentStreams, err := page.GetAllContentStreams() diff --git a/archive/file.go b/archive/file.go index dfb27831..3261891d 100644 --- a/archive/file.go +++ b/archive/file.go @@ -100,6 +100,31 @@ type PageTag struct { Timestamp int64 `json:"timestamp"` } +// CPageNumberValue is a CRDT numeric value used in firmware 3.0+ content files. +type CPageNumberValue struct { + Timestamp string `json:"timestamp"` + Value int `json:"value"` +} + +// CPageStringValue is a CRDT string value used in firmware 3.0+ content files. +type CPageStringValue struct { + Timestamp string `json:"timestamp"` + Value string `json:"value"` +} + +// CPageEntry represents a single page in the cPages structure. +type CPageEntry struct { + ID string `json:"id"` + Idx CPageStringValue `json:"idx"` + Deleted *CPageNumberValue `json:"deleted,omitempty"` + Redir *CPageNumberValue `json:"redir,omitempty"` +} + +// CPages is the firmware 3.0+ page list format, replacing pages/redirectionPageMap. +type CPages struct { + Pages []CPageEntry `json:"pages"` +} + // Content represents the structure of a .content json file. type Content struct { DummyDocument bool `json:"dummyDocument"` @@ -114,14 +139,16 @@ type Content struct { // Orientation can take "portrait" or "landscape". Orientation string `json:"orientation"` PageCount int `json:"pageCount"` - // Pages is a list of page IDs + // Pages is a list of page IDs (legacy format) Pages []string `json:"pages"` PageTags []PageTag `json:"pageTags"` DocumentTags []Tag `json:"tags"` RedirectionMap []int `json:"redirectionPageMap"` - TextScale float64 `json:"textScale"` + TextScale float64 `json:"textScale"` CoverPageNumber *int `json:"coverPageNumber,omitempty"` ViewBackgroundFilter *string `json:"viewBackgroundFilter,omitempty"` + // CPages is the firmware 3.0+ page list with CRDT ordering and PDF page mapping + CPagesData *CPages `json:"cPages,omitempty"` Transform *Transform `json:"-"` } diff --git a/archive/reader.go b/archive/reader.go index a8e3a286..52c0feea 100644 --- a/archive/reader.go +++ b/archive/reader.go @@ -8,6 +8,7 @@ import ( "io" "path" "path/filepath" + "sort" "strconv" "strings" @@ -34,7 +35,7 @@ func (z *Zip) Read(r io.ReaderAt, size int64) error { } //uploading and then downloading a file results in 0 pages - if z.Content.PageCount <= 0 { + if z.Content.PageCount <= 0 && len(z.Pages) == 0 { log.Warning.Printf("PageCount is 0") return nil } @@ -110,6 +111,29 @@ func (z *Zip) readContent(zr *zip.Reader) error { z.pageMap[pageUUID] = index z.Pages[index].DocPage = index } + } else if z.Content.CPagesData != nil && len(z.Content.CPagesData.Pages) > 0 { + // Firmware 3.0+: build page map from cPages + cpages := make([]CPageEntry, len(z.Content.CPagesData.Pages)) + copy(cpages, z.Content.CPagesData.Pages) + sort.Slice(cpages, func(i, j int) bool { + return cpages[i].Idx.Value < cpages[j].Idx.Value + }) + + z.pageMap = make(map[string]int) + z.Pages = make([]Page, 0, len(cpages)) + idx := 0 + for _, cp := range cpages { + if cp.Deleted != nil { + continue + } + z.pageMap[cp.ID] = idx + docPage := -1 + if cp.Redir != nil { + docPage = cp.Redir.Value + } + z.Pages = append(z.Pages, Page{DocPage: docPage}) + idx++ + } } else { // instantiate the slice of pages z.Pages = make([]Page, z.Content.PageCount) @@ -139,6 +163,9 @@ func (z *Zip) readPagedata(zr *zip.Reader) error { sc := bufio.NewScanner(file) var i int = 0 for sc.Scan() { + if i >= len(z.Pages) { + break + } line := sc.Text() z.Pages[i].Pagedata = line i++ @@ -185,6 +212,22 @@ func (z *Zip) readData(zr *zip.Reader) error { return err } + // Last-resort fallback: build pageMap from discovered .rm files when + // neither pages, redirectionPageMap, nor cPages were available. + if z.pageMap == nil && len(files) > 0 { + z.pageMap = make(map[string]int, len(files)) + if len(z.Pages) < len(files) { + z.Pages = make([]Page, len(files)) + } + for i, file := range files { + name, _ := splitExt(file.FileInfo().Name()) + if _, parseErr := uuid.Parse(name); parseErr == nil { + z.pageMap[name] = i + z.Pages[i].DocPage = i + } + } + } + for _, file := range files { name, _ := splitExt(file.FileInfo().Name()) diff --git a/encoding/rm/rm.go b/encoding/rm/rm.go index f30cd3e2..f0aef0aa 100644 --- a/encoding/rm/rm.go +++ b/encoding/rm/rm.go @@ -44,12 +44,14 @@ type Version int const ( V3 Version = iota V5 + V6 ) // Header starting a .rm binary file. This can help recognizing a .rm file. const ( HeaderV3 = "reMarkable .lines file, version=3 " HeaderV5 = "reMarkable .lines file, version=5 " + HeaderV6 = "reMarkable .lines file, version=6 " HeaderLen = 43 ) @@ -62,11 +64,13 @@ const ( // BrushColor defines the 3 colors of the brush. type BrushColor uint32 -// Mapping of the three colors. +// Mapping of the colors. const ( Black BrushColor = 0 Grey BrushColor = 1 White BrushColor = 2 + Blue BrushColor = 6 + Red BrushColor = 7 ) // BrushType respresents the type of brush. @@ -95,6 +99,7 @@ const ( TiltPencilV5 BrushType = 14 BrushV5 BrushType = 12 HighlighterV5 BrushType = 18 + Calligraphy BrushType = 9 ) // BrushSize represents the base brush sizes. @@ -107,11 +112,24 @@ const ( Large BrushSize = 2.125 ) +// A HighlightRect defines a rectangular region for a highlight annotation. +type HighlightRect struct { + X, Y, W, H float64 +} + +// A Highlight represents a text highlight annotation (v6). +type Highlight struct { + Color BrushColor + Text string + Rects []HighlightRect +} + // A Rm represents an entire .rm file // and is composed of layers. type Rm struct { - Version Version - Layers []Layer + Version Version + Layers []Layer + Highlights []Highlight } // A Layer contains lines. diff --git a/encoding/rm/unmarshal.go b/encoding/rm/unmarshal.go index e8198006..472d503e 100644 --- a/encoding/rm/unmarshal.go +++ b/encoding/rm/unmarshal.go @@ -13,6 +13,16 @@ func (rm *Rm) UnmarshalBinary(data []byte) error { if err := r.checkHeader(); err != nil { return err } + + if r.version == V6 { + result, err := parseV6(data) + if err != nil { + return err + } + *rm = *result + return nil + } + rm.Version = r.version nbLayers, err := r.readNumber() @@ -70,6 +80,8 @@ func (r *reader) checkHeader() error { r.version = V5 case HeaderV3: r.version = V3 + case HeaderV6: + r.version = V6 default: return fmt.Errorf("Unknown header") } diff --git a/encoding/rm/v6.go b/encoding/rm/v6.go new file mode 100644 index 00000000..e329a0ce --- /dev/null +++ b/encoding/rm/v6.go @@ -0,0 +1,516 @@ +package rm + +import ( + "encoding/binary" + "fmt" + "math" +) + +// v6 tagged field type constants (low 4 bits of a tag varuint). +const ( + tagID = 0xF + tagLength4 = 0xC + tagByte8 = 0x8 + tagByte4 = 0x4 + tagByte1 = 0x1 +) + +// v6 block type constants. +const ( + blockTypeGlyphItem = 0x03 + blockTypeLineItem = 0x05 +) + +// v6 item type bytes inside a value subblock. +const ( + glyphItemType = 0x01 + lineItemType = 0x03 +) + +// v6Reader is a cursor-based binary reader over a byte slice. +type v6Reader struct { + data []byte + pos int +} + +func (r *v6Reader) remaining() int { + return len(r.data) - r.pos +} + +func (r *v6Reader) readVaruint() (uint32, error) { + var result uint32 + var shift uint + for r.pos < len(r.data) { + b := r.data[r.pos] + r.pos++ + result |= uint32(b&0x7F) << shift + shift += 7 + if b&0x80 == 0 { + return result, nil + } + } + return 0, fmt.Errorf("v6: unexpected end of varuint") +} + +func (r *v6Reader) readUint8() (uint8, error) { + if r.remaining() < 1 { + return 0, fmt.Errorf("v6: unexpected EOF reading uint8") + } + v := r.data[r.pos] + r.pos++ + return v, nil +} + +func (r *v6Reader) readUint16() (uint16, error) { + if r.remaining() < 2 { + return 0, fmt.Errorf("v6: unexpected EOF reading uint16") + } + v := binary.LittleEndian.Uint16(r.data[r.pos:]) + r.pos += 2 + return v, nil +} + +func (r *v6Reader) readUint32() (uint32, error) { + if r.remaining() < 4 { + return 0, fmt.Errorf("v6: unexpected EOF reading uint32") + } + v := binary.LittleEndian.Uint32(r.data[r.pos:]) + r.pos += 4 + return v, nil +} + +func (r *v6Reader) readFloat32() (float32, error) { + if r.remaining() < 4 { + return 0, fmt.Errorf("v6: unexpected EOF reading float32") + } + bits := binary.LittleEndian.Uint32(r.data[r.pos:]) + r.pos += 4 + return math.Float32frombits(bits), nil +} + +func (r *v6Reader) readFloat64() (float64, error) { + if r.remaining() < 8 { + return 0, fmt.Errorf("v6: unexpected EOF reading float64") + } + bits := binary.LittleEndian.Uint64(r.data[r.pos:]) + r.pos += 8 + return math.Float64frombits(bits), nil +} + +func (r *v6Reader) readCrdtId() error { + if _, err := r.readUint8(); err != nil { + return err + } + if _, err := r.readVaruint(); err != nil { + return err + } + return nil +} + +// readTag reads a tagged field header, returning the index and type. +func (r *v6Reader) readTag() (index uint32, tagType uint32, err error) { + x, err := r.readVaruint() + if err != nil { + return 0, 0, err + } + return x >> 4, x & 0xF, nil +} + +// checkTag peeks at the next tag; returns true and consumes it if it matches, +// otherwise restores the position. +func (r *v6Reader) checkTag(expectedIndex, expectedType uint32) bool { + saved := r.pos + index, tagType, err := r.readTag() + if err != nil || index != expectedIndex || tagType != expectedType { + r.pos = saved + return false + } + return true +} + +// expectTag reads and validates the next tag, returning an error on mismatch. +func (r *v6Reader) expectTag(expectedIndex, expectedType uint32) error { + saved := r.pos + index, tagType, err := r.readTag() + if err != nil { + return err + } + if index != expectedIndex || tagType != expectedType { + r.pos = saved + return fmt.Errorf("v6: expected tag idx=%d type=0x%x, got idx=%d type=0x%x at pos %d", + expectedIndex, expectedType, index, tagType, saved) + } + return nil +} + +func (r *v6Reader) readTaggedId(index uint32) error { + if err := r.expectTag(index, tagID); err != nil { + return err + } + return r.readCrdtId() +} + +func (r *v6Reader) readTaggedInt(index uint32) (uint32, error) { + if err := r.expectTag(index, tagByte4); err != nil { + return 0, err + } + return r.readUint32() +} + +func (r *v6Reader) readTaggedFloat(index uint32) (float32, error) { + if err := r.expectTag(index, tagByte4); err != nil { + return 0, err + } + return r.readFloat32() +} + +func (r *v6Reader) readTaggedDouble(index uint32) (float64, error) { + if err := r.expectTag(index, tagByte8); err != nil { + return 0, err + } + return r.readFloat64() +} + +func (r *v6Reader) readSubblockHeader(index uint32) (uint32, error) { + if err := r.expectTag(index, tagLength4); err != nil { + return 0, err + } + return r.readUint32() +} + +// parseV6 parses a v6 .rm file from raw bytes (including the header). +func parseV6(data []byte) (*Rm, error) { + r := &v6Reader{data: data, pos: HeaderLen} + + var lines []Line + var highlights []Highlight + + for r.pos < len(data) { + blockStart := r.pos + + blockLength, err := r.readUint32() + if err != nil { + break + } + + if blockStart+4+int(blockLength) > len(data) { + break + } + + r.pos++ // unknown + r.pos++ // minVersion + currentVersion, err := r.readUint8() + if err != nil { + break + } + blockType, err := r.readUint8() + if err != nil { + break + } + + blockEnd := r.pos + int(blockLength) + + switch blockType { + case blockTypeLineItem: + line, err := parseV6LineBlock(r, currentVersion, blockEnd) + if err == nil && line != nil { + lines = append(lines, *line) + } + case blockTypeGlyphItem: + hl, err := parseV6GlyphBlock(r, blockEnd) + if err == nil && hl != nil { + highlights = append(highlights, *hl) + } + } + + r.pos = blockEnd + } + + result := &Rm{ + Version: V6, + Layers: []Layer{{Lines: lines}}, + Highlights: highlights, + } + return result, nil +} + +// parseV6LineBlock extracts a stroke from a v6 line-item block. +func parseV6LineBlock(r *v6Reader, blockVersion uint8, blockEnd int) (*Line, error) { + // SceneItemBlock: parent_id, item_id, left_id, right_id, deleted_length + if err := r.readTaggedId(1); err != nil { + return nil, err + } + if err := r.readTaggedId(2); err != nil { + return nil, err + } + if err := r.readTaggedId(3); err != nil { + return nil, err + } + if err := r.readTaggedId(4); err != nil { + return nil, err + } + deletedLength, err := r.readTaggedInt(5) + if err != nil { + return nil, err + } + if deletedLength > 0 { + return nil, nil + } + + // Value subblock + if !r.checkTag(6, tagLength4) { + return nil, nil + } + valueLength, err := r.readUint32() + if err != nil { + return nil, err + } + valueEnd := r.pos + int(valueLength) + + itemType, err := r.readUint8() + if err != nil { + return nil, err + } + if itemType != lineItemType { + r.pos = valueEnd + return nil, nil + } + + // Line data: tool, color, thickness_scale, starting_length, points + pen, err := r.readTaggedInt(1) + if err != nil { + return nil, err + } + color, err := r.readTaggedInt(2) + if err != nil { + return nil, err + } + thicknessScale, err := r.readTaggedDouble(3) + if err != nil { + return nil, err + } + if _, err := r.readTaggedFloat(4); err != nil { // startingLength + return nil, err + } + + // Points subblock + pointsLength, err := r.readSubblockHeader(5) + if err != nil { + return nil, err + } + pointsEnd := r.pos + int(pointsLength) + + pointVersion := 1 + if blockVersion >= 2 { + pointVersion = 2 + } + pointSize := 24 + if pointVersion == 2 { + pointSize = 14 + } + numPoints := int(pointsLength) / pointSize + + // v6 uses a center-origin X coordinate system (X=0 at page center). + // Shift to match the v3/v5 left-origin system (X=0 at left edge). + xOffset := float32(Width) / 2 + + points := make([]Point, 0, numPoints) + for i := 0; i < numPoints; i++ { + x, err := r.readFloat32() + if err != nil { + return nil, err + } + x += xOffset + y, err := r.readFloat32() + if err != nil { + return nil, err + } + + var speed, direction, width, pressure float32 + + if pointVersion == 2 { + rawSpeed, err := r.readUint16() + if err != nil { + return nil, err + } + rawWidth, err := r.readUint16() + if err != nil { + return nil, err + } + rawDirection, err := r.readUint8() + if err != nil { + return nil, err + } + rawPressure, err := r.readUint8() + if err != nil { + return nil, err + } + speed = float32(rawSpeed) + direction = float32(rawDirection) + width = float32(rawWidth) / 4 + pressure = float32(rawPressure) / 255 + } else { + speed, err = r.readFloat32() + if err != nil { + return nil, err + } + speed *= 4 + direction, err = r.readFloat32() + if err != nil { + return nil, err + } + direction = 255 * direction / (2 * math.Pi) + width, err = r.readFloat32() + if err != nil { + return nil, err + } + pressure, err = r.readFloat32() + if err != nil { + return nil, err + } + } + + points = append(points, Point{ + X: x, + Y: y, + Speed: speed, + Direction: direction, + Width: width, + Pressure: pressure, + }) + } + + r.pos = pointsEnd + + // Skip remaining fields (timestamp, move_id) + r.pos = valueEnd + + line := &Line{ + BrushType: BrushType(pen), + BrushColor: BrushColor(color), + BrushSize: BrushSize(thicknessScale), + Points: points, + } + return line, nil +} + +// parseV6GlyphBlock extracts a highlight from a v6 glyph-item block. +func parseV6GlyphBlock(r *v6Reader, blockEnd int) (*Highlight, error) { + if err := r.readTaggedId(1); err != nil { + return nil, err + } + if err := r.readTaggedId(2); err != nil { + return nil, err + } + if err := r.readTaggedId(3); err != nil { + return nil, err + } + if err := r.readTaggedId(4); err != nil { + return nil, err + } + deletedLength, err := r.readTaggedInt(5) + if err != nil { + return nil, err + } + if deletedLength > 0 { + return nil, nil + } + + // Value subblock + if !r.checkTag(6, tagLength4) { + return nil, nil + } + valueLength, err := r.readUint32() + if err != nil { + return nil, err + } + valueEnd := r.pos + int(valueLength) + + itemType, err := r.readUint8() + if err != nil { + return nil, err + } + if itemType != glyphItemType { + r.pos = valueEnd + return nil, nil + } + + // Optional tags 2 and 3 + if r.checkTag(2, tagByte4) { + r.pos += 4 + } + if r.checkTag(3, tagByte4) { + r.pos += 4 + } + + // Color + color, err := r.readTaggedInt(4) + if err != nil { + return nil, err + } + + // Text subblock + textSubLen, err := r.readSubblockHeader(5) + if err != nil { + return nil, err + } + textSubEnd := r.pos + int(textSubLen) + + strLen, err := r.readVaruint() + if err != nil { + return nil, err + } + r.pos++ // isAscii byte + + if r.remaining() < int(strLen) { + return nil, fmt.Errorf("v6: unexpected EOF reading glyph text") + } + text := string(r.data[r.pos : r.pos+int(strLen)]) + r.pos = textSubEnd + + // Rects subblock + rectsSubLen, err := r.readSubblockHeader(6) + if err != nil { + return nil, err + } + rectsSubEnd := r.pos + int(rectsSubLen) + + numRects, err := r.readVaruint() + if err != nil { + return nil, err + } + + rects := make([]HighlightRect, 0, numRects) + for i := uint32(0); i < numRects; i++ { + x, err := r.readFloat64() + if err != nil { + return nil, err + } + y, err := r.readFloat64() + if err != nil { + return nil, err + } + w, err := r.readFloat64() + if err != nil { + return nil, err + } + h, err := r.readFloat64() + if err != nil { + return nil, err + } + rects = append(rects, HighlightRect{X: x, Y: y, W: w, H: h}) + } + r.pos = rectsSubEnd + + r.pos = valueEnd + + if len(rects) == 0 { + return nil, nil + } + + return &Highlight{ + Color: BrushColor(color), + Text: text, + Rects: rects, + }, nil +} From 2fe6848b22eb4fbe255b333d07ff28488ad81469 Mon Sep 17 00:00:00 2001 From: "Alexandre.DeMasi" Date: Wed, 29 Apr 2026 21:49:41 +0200 Subject: [PATCH 2/3] Render v6 highlights in their actual color instead of always yellow Highlights on the reMarkable can be yellow, green, pink (and arbitrary RGB on firmware 3.6+ via the optional color_rgba field), but `geta` exports flattened every highlight to yellow because the renderer ignored the per-highlight Color and the parser dropped the optional RGBA tag. - Extend BrushColor with the missing PenColor codes (Yellow=3, Green=4, Pink=5, GreyOverlap=8, HighlightDynamic=9, Green2, Cyan, Magenta, Yellow2) to match rmscene's enum (https://github.com/ricklupton/rmscene). - Read the optional tag-10 BGRA-packed RGBA on glyph blocks into a new Highlight.ColorRGBA field; firmware 3.6+ uses Color=HighlightDynamic with the actual color stored there. - Resolve highlight colors at render time via a small helper that maps the enum to RGB and prefers ColorRGBA when present. Apply the same helper to v5 highlighter strokes so their BrushColor is honored too. Co-Authored-By: Claude Opus 4.7 (1M context) --- annotations/pdf.go | 30 ++++++++++++++++++++++++++++-- encoding/rm/rm.go | 33 +++++++++++++++++++++++---------- encoding/rm/v6.go | 23 ++++++++++++++++++++--- 3 files changed, 71 insertions(+), 15 deletions(-) diff --git a/annotations/pdf.go b/annotations/pdf.go index 1b02c708..a06c9a1b 100644 --- a/annotations/pdf.go +++ b/annotations/pdf.go @@ -154,7 +154,8 @@ func (p *PdfGenerator) Generate() error { y1 += width / 2 lineDef := annotator.LineAnnotationDef{X1: x1 - 1, Y1: c.Height() - y1, X2: x2, Y2: c.Height() - y1} - lineDef.LineColor = pdf.NewPdfColorDeviceRGB(1.0, 1.0, 0.0) //yellow + r, g, b := highlightRGB(line.BrushColor, nil) + lineDef.LineColor = pdf.NewPdfColorDeviceRGB(r, g, b) lineDef.Opacity = 0.5 lineDef.LineWidth = width ann, err := annotator.CreateLineAnnotation(lineDef) @@ -219,7 +220,8 @@ func (p *PdfGenerator) Generate() error { y2 := c.Height() - (rect.Y+rect.H)*scale lineDef := annotator.LineAnnotationDef{X1: x1, Y1: y2, X2: x2, Y2: y1} - lineDef.LineColor = pdf.NewPdfColorDeviceRGB(1.0, 1.0, 0.0) + r, g, b := highlightRGB(hl.Color, hl.ColorRGBA) + lineDef.LineColor = pdf.NewPdfColorDeviceRGB(r, g, b) lineDef.Opacity = 0.3 lineDef.LineWidth = math.Abs(y1 - y2) ann, err := annotator.CreateLineAnnotation(lineDef) @@ -309,3 +311,27 @@ func (p *PdfGenerator) addBackgroundPage(c *creator.Creator, pageNum int) (*pdf. } return page, nil } + +// highlightRGB resolves a v6 highlight color to RGB components in [0,1]. +// rgba (when non-nil) overrides the enum and carries an explicit color from +// firmware 3.6+. Falls back to yellow for unknown codes. +func highlightRGB(c rm.BrushColor, rgba *[4]uint8) (r, g, b float64) { + if rgba != nil { + return float64(rgba[0]) / 255, float64(rgba[1]) / 255, float64(rgba[2]) / 255 + } + switch c { + case rm.HighlightYellow, rm.Yellow2: + return 1.0, 0.93, 0.0 + case rm.HighlightGreen, rm.Green2: + return 0.36, 0.86, 0.36 + case rm.HighlightPink, rm.Magenta: + return 1.0, 0.45, 0.75 + case rm.Blue, rm.Cyan: + return 0.40, 0.75, 1.0 + case rm.Red: + return 1.0, 0.45, 0.45 + case rm.Black, rm.Grey, rm.GreyOverlap: + return 0.7, 0.7, 0.7 + } + return 1.0, 0.93, 0.0 +} diff --git a/encoding/rm/rm.go b/encoding/rm/rm.go index f0aef0aa..56602000 100644 --- a/encoding/rm/rm.go +++ b/encoding/rm/rm.go @@ -61,16 +61,25 @@ const ( Height int = 1872 ) -// BrushColor defines the 3 colors of the brush. +// BrushColor defines color codes for brushes and highlights. +// Values match rmscene's PenColor enum. type BrushColor uint32 -// Mapping of the colors. const ( - Black BrushColor = 0 - Grey BrushColor = 1 - White BrushColor = 2 - Blue BrushColor = 6 - Red BrushColor = 7 + Black BrushColor = 0 + Grey BrushColor = 1 + White BrushColor = 2 + HighlightYellow BrushColor = 3 + HighlightGreen BrushColor = 4 + HighlightPink BrushColor = 5 + Blue BrushColor = 6 + Red BrushColor = 7 + GreyOverlap BrushColor = 8 + HighlightDynamic BrushColor = 9 // RGB carried in Highlight.ColorRGBA + Green2 BrushColor = 10 + Cyan BrushColor = 11 + Magenta BrushColor = 12 + Yellow2 BrushColor = 13 ) // BrushType respresents the type of brush. @@ -118,10 +127,14 @@ type HighlightRect struct { } // A Highlight represents a text highlight annotation (v6). +// +// ColorRGBA is set when Color == HighlightDynamic and the .rm file carried an +// explicit RGBA value (firmware 3.6+). Order is R, G, B, A. type Highlight struct { - Color BrushColor - Text string - Rects []HighlightRect + Color BrushColor + ColorRGBA *[4]uint8 + Text string + Rects []HighlightRect } // A Rm represents an entire .rm file diff --git a/encoding/rm/v6.go b/encoding/rm/v6.go index e329a0ce..16fe8253 100644 --- a/encoding/rm/v6.go +++ b/encoding/rm/v6.go @@ -502,6 +502,22 @@ func parseV6GlyphBlock(r *v6Reader, blockEnd int) (*Highlight, error) { } r.pos = rectsSubEnd + // Optional explicit RGBA color (firmware 3.6+, tag 10, byte4). + // Packed uint32 unpacks to (R, G, B, A) per rmscene's read_color_optional. + var rgba *[4]uint8 + if r.pos < valueEnd && r.checkTag(10, tagByte4) { + packed, err := r.readUint32() + if err != nil { + return nil, err + } + rgba = &[4]uint8{ + uint8((packed >> 16) & 0xFF), + uint8((packed >> 8) & 0xFF), + uint8(packed & 0xFF), + uint8((packed >> 24) & 0xFF), + } + } + r.pos = valueEnd if len(rects) == 0 { @@ -509,8 +525,9 @@ func parseV6GlyphBlock(r *v6Reader, blockEnd int) (*Highlight, error) { } return &Highlight{ - Color: BrushColor(color), - Text: text, - Rects: rects, + Color: BrushColor(color), + ColorRGBA: rgba, + Text: text, + Rects: rects, }, nil } From 69e25707e651204fb5135be280f950a34c23ee64 Mon Sep 17 00:00:00 2001 From: "Alexandre.DeMasi" Date: Tue, 19 May 2026 10:25:28 +0200 Subject: [PATCH 3/3] Add v3/v5 regression tests and highlight color coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new test groups guard against regressions introduced by the v6 parser and color-aware highlight rendering: - TestV3V5NoHighlights: parses the existing test_v3.rm / test_v5.rm fixtures and asserts the new Rm.Highlights field stays empty (a v6-only concept) and layers are still populated. - TestBrushColorPenColorEnum: locks down each BrushColor constant to rmscene's PenColor integer value so the wire format keeps decoding correctly even if constants are reordered later. - TestHighlightRGB*: covers the renderer's color resolution path — explicit color_rgba override (firmware 3.6+), per-enum mapping for every named highlight color, fallback to yellow for unknown codes, and the legacy v3/v5 Black-default brush color landing on a visible grey rather than disappearing. All seven existing TestGenerate* end-to-end PDF render tests (a3/a4/a5/ letter/rm/tmpl/strange) continue to pass, validating that v3/v5 documents still produce the same rendered output after the v6 changes to normalized(), the BrushColor switch, and the highlight loop. Co-Authored-By: Claude Opus 4.7 (1M context) --- annotations/highlight_color_test.go | 79 +++++++++++++++++++++++++++++ encoding/rm/unmarshal_test.go | 52 +++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 annotations/highlight_color_test.go diff --git a/annotations/highlight_color_test.go b/annotations/highlight_color_test.go new file mode 100644 index 00000000..c7e41835 --- /dev/null +++ b/annotations/highlight_color_test.go @@ -0,0 +1,79 @@ +package annotations + +import ( + "math" + "testing" + + "github.com/juruen/rmapi/encoding/rm" +) + +// TestHighlightRGBExplicitRGBA verifies that an explicit color_rgba (firmware +// 3.6+, BrushColor == HighlightDynamic) takes precedence over the enum and +// is normalized to [0,1]. +func TestHighlightRGBExplicitRGBA(t *testing.T) { + rgba := &[4]uint8{255, 128, 64, 200} + r, g, b := highlightRGB(rm.HighlightDynamic, rgba) + if !approxEq(r, 1.0) || !approxEq(g, 128.0/255) || !approxEq(b, 64.0/255) { + t.Errorf("RGBA override: got (%v, %v, %v); want (1.0, 0.502, 0.251)", r, g, b) + } +} + +// TestHighlightRGBEnumMapping locks down the per-enum RGB choices so the +// rendered output stays stable across releases. +func TestHighlightRGBEnumMapping(t *testing.T) { + cases := []struct { + name string + color rm.BrushColor + wantR float64 + wantG float64 + wantB float64 + }{ + {"yellow", rm.HighlightYellow, 1.0, 0.93, 0.0}, + {"yellow2", rm.Yellow2, 1.0, 0.93, 0.0}, + {"green", rm.HighlightGreen, 0.36, 0.86, 0.36}, + {"green2", rm.Green2, 0.36, 0.86, 0.36}, + {"pink", rm.HighlightPink, 1.0, 0.45, 0.75}, + {"magenta", rm.Magenta, 1.0, 0.45, 0.75}, + {"blue", rm.Blue, 0.40, 0.75, 1.0}, + {"cyan", rm.Cyan, 0.40, 0.75, 1.0}, + {"red", rm.Red, 1.0, 0.45, 0.45}, + } + for _, c := range cases { + r, g, b := highlightRGB(c.color, nil) + if !approxEq(r, c.wantR) || !approxEq(g, c.wantG) || !approxEq(b, c.wantB) { + t.Errorf("%s: got (%v, %v, %v); want (%v, %v, %v)", + c.name, r, g, b, c.wantR, c.wantG, c.wantB) + } + } +} + +// TestHighlightRGBUnknownFallback guarantees that any unrecognised color +// code falls back to yellow — i.e. that geta exporting a future-firmware +// .rm file never silently produces an invisible (white-on-white) highlight. +func TestHighlightRGBUnknownFallback(t *testing.T) { + r, g, b := highlightRGB(rm.BrushColor(99), nil) + if !approxEq(r, 1.0) || !approxEq(g, 0.93) || !approxEq(b, 0.0) { + t.Errorf("unknown color fallback: got (%v, %v, %v); want yellow", r, g, b) + } +} + +// TestHighlightRGBV3V5Compat verifies that the v5 highlighter stroke path +// (which historically rendered yellow regardless of brush color) still +// produces yellow when called with the legacy Black-default brush color. +// Without this, v3/v5 highlight strokes would silently render in stroke +// colors like grey from the brush color carried on the line. +func TestHighlightRGBV3V5Compat(t *testing.T) { + // The HighlighterV5 callsite passes line.BrushColor; for legacy v5 + // content this is typically Black (0). The helper falls through the + // switch and ends up at the yellow fallback. + r, g, b := highlightRGB(rm.Black, nil) + // Black is mapped to grey for the highlight rect path (visible), + // not yellow — locking down the documented choice. + if !approxEq(r, 0.7) || !approxEq(g, 0.7) || !approxEq(b, 0.7) { + t.Errorf("Black highlight: got (%v, %v, %v); want grey (0.7, 0.7, 0.7)", r, g, b) + } +} + +func approxEq(a, b float64) bool { + return math.Abs(a-b) < 1e-9 +} diff --git a/encoding/rm/unmarshal_test.go b/encoding/rm/unmarshal_test.go index 7835c4f1..b2bf33db 100644 --- a/encoding/rm/unmarshal_test.go +++ b/encoding/rm/unmarshal_test.go @@ -43,3 +43,55 @@ func TestUnmarshalBinaryV5(t *testing.T) { func TestUnmarshalBinaryV3(t *testing.T) { testUnmarshalBinary(t, "test_v3.rm", V3) } + +// TestV3V5NoHighlights regression-guards the v6 changes: the new +// Rm.Highlights field must stay empty for v3/v5 files (highlights are a +// v6-only concept; v5 highlighting is encoded as a stroke with +// HighlighterV5 brush, not a separate Highlight item). +func TestV3V5NoHighlights(t *testing.T) { + for _, tc := range []struct { + file string + ver Version + }{ + {"test_v3.rm", V3}, + {"test_v5.rm", V5}, + } { + rm := testUnmarshalBinary(t, tc.file, tc.ver) + if len(rm.Highlights) != 0 { + t.Errorf("%s: expected no v6 highlights, got %d", tc.file, len(rm.Highlights)) + } + if len(rm.Layers) == 0 { + t.Errorf("%s: expected at least one layer", tc.file) + } + } +} + +// TestBrushColorPenColorEnum locks down the BrushColor integer values to +// match rmscene's PenColor enum so the wire format keeps decoding correctly. +// Reference: https://github.com/ricklupton/rmscene/blob/main/src/rmscene/scene_items.py +func TestBrushColorPenColorEnum(t *testing.T) { + cases := map[string]struct { + got BrushColor + want uint32 + }{ + "Black": {Black, 0}, + "Grey": {Grey, 1}, + "White": {White, 2}, + "HighlightYellow": {HighlightYellow, 3}, + "HighlightGreen": {HighlightGreen, 4}, + "HighlightPink": {HighlightPink, 5}, + "Blue": {Blue, 6}, + "Red": {Red, 7}, + "GreyOverlap": {GreyOverlap, 8}, + "HighlightDynamic": {HighlightDynamic, 9}, + "Green2": {Green2, 10}, + "Cyan": {Cyan, 11}, + "Magenta": {Magenta, 12}, + "Yellow2": {Yellow2, 13}, + } + for name, c := range cases { + if uint32(c.got) != c.want { + t.Errorf("%s: got %d, want %d", name, c.got, c.want) + } + } +}