Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,32 @@ RUN pnpm install && pnpm build
FROM golang:bookworm AS gobuilder
ARG VERSION
WORKDIR /src

# Install Cairo development libraries for native rmc-go
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libcairo2-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*

COPY . .
COPY --from=uibuilder /src/dist ./ui/dist
#RUN apk add git
RUN go generate ./... && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION}" -o rmfakecloud-docker ./cmd/rmfakecloud/

FROM scratch
# Build with Cairo support (native rmc-go)
RUN go generate ./... && \
CGO_ENABLED=1 go build -tags cairo -ldflags "-s -w -X main.version=${VERSION}" -o rmfakecloud-docker ./cmd/rmfakecloud/

# Final runtime image - use Debian slim instead of Python
FROM debian:bookworm-slim
EXPOSE 3000
ADD ./docker/rootfs.tar /
COPY --from=gobuilder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=gobuilder /src/rmfakecloud-docker /
ENTRYPOINT ["/rmfakecloud-docker"]

# Install runtime dependencies for Cairo
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
libcairo2 \
&& rm -rf /var/lib/apt/lists/*

COPY --from=gobuilder /src/rmfakecloud-docker /rmfakecloud

ENTRYPOINT ["/rmfakecloud"]
14 changes: 12 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ LDFLAGS := "-s -w -X main.version=$(VERSION)"
OUT_DIR := dist
CMD := ./cmd/rmfakecloud
BINARY := rmfakecloud
BUILD = go build -ldflags $(LDFLAGS) -o $(@) $(CMD)
BUILD = go build -ldflags $(LDFLAGS) -o $(@) $(CMD)
BUILD_CAIRO = CGO_ENABLED=1 go build -tags cairo -ldflags $(LDFLAGS) -o $(@) $(CMD)
ASSETS = ui/dist
GOFILES := $(shell find . -iname '*.go' ! -iname "*_test.go")
GOFILES += $(ASSETS)
Expand All @@ -13,10 +14,12 @@ UIFILES += ui/package.json
TARGETS := $(addprefix $(OUT_DIR)/$(BINARY)-, x64 armv6 armv7 arm64 win64 docker)
PNPM = cd ui; pnpm

.PHONY: all run runui clean test testgo testui
.PHONY: all run runui clean test testgo testui build-cairo

build: $(OUT_DIR)/$(BINARY)-x64

build-cairo: $(OUT_DIR)/$(BINARY)-cairo-x64

all: $(TARGETS)

$(OUT_DIR)/$(BINARY)-x64:$(GOFILES)
Expand All @@ -37,6 +40,13 @@ $(OUT_DIR)/$(BINARY)-arm64:$(GOFILES)
$(OUT_DIR)/$(BINARY)-docker:$(GOFILES)
CGO_ENABLED=0 $(BUILD)

# Cairo-enabled builds (native rmc-go support)
$(OUT_DIR)/$(BINARY)-cairo-x64:$(GOFILES)
GOOS=linux $(BUILD_CAIRO)

$(OUT_DIR)/$(BINARY)-cairo-arm64:$(GOFILES)
GOOS=linux GOARCH=arm64 $(BUILD_CAIRO)

container: $(OUT_DIR)/$(BINARY)-docker
docker build -t rmfakecloud -f Dockerfile.make .

Expand Down
54 changes: 53 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ Use the `rmfakecloud-proxy` from [toltec](https://github.com/toltec-dev/toltec/)
| [Messaging integration through webhook](https://ddvk.github.io/rmfakecloud/usage/integrations/#messaging-webhook) | ✅ | |
| Messaging integration to Slack | 🟡 | Not directly, use a webhook with zapier/make/n8n |
| Archive document to cloud | 🟡 | It works but the information is not saved |
| Document rendering in web interface | ❌ | [WIP](https://github.com/ddvk/rmfakecloud/issues/255) |
| Document rendering in web interface | ✅ | |
| v6 file format support (software 3.0+) | ✅ | Native in-process rendering with rmc-go |


## Breaking Changes
Expand All @@ -49,6 +50,57 @@ Use the `rmfakecloud-proxy` from [toltec](https://github.com/toltec-dev/toltec/)
or modify the profile and add `sync15:true`
a full resync will be needed (the tablet will do it), the old files are kept as they were and everything is put in a new directory

## v6 File Format Support

rmfakecloud natively supports v6 file format (introduced in reMarkable software 3.0+) for PDF export via the web UI.

### How It Works

- **v5 files** (software < 3.0): Rendered using built-in rmapi library
- **v6 files** (software >= 3.0): Rendered natively using integrated rmc-go library with Cairo

### Requirements

**Docker (recommended)**: All dependencies included automatically.

**Manual installation**:
- Cairo development libraries for building:
- macOS: `brew install cairo pkg-config`
- Ubuntu/Debian: `apt-get install libcairo2-dev pkg-config`
- Fedora: `dnf install cairo-devel`
- Cairo runtime library for running (installed automatically on most systems)

### Building

```bash
# Build with native v6 support (Cairo)
make build-cairo

# Or for standard build
make build
```

### Docker Usage

The Docker image includes native v6 support:

```bash
docker run -d -p 3000:3000 \
-v $PWD/data:/data \
ddvk/rmfakecloud:latest
```

### Performance

- **v6 rendering**: ~100-500ms per page (in-process, no external dependencies)
- **v5 rendering**: Similar performance with rmapi
- **Caching**: Results cached for faster subsequent access

### Known Limitations

- Multi-page v6 documents: Fully supported
- Text rendering: Fully supported

## Development

run `./dev.sh` which should start the UI and backend
Expand Down
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
module github.com/ddvk/rmfakecloud

go 1.23.3

toolchain go1.24.1
go 1.25.1

require (
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.1
github.com/joagonca/rmc-go v1.1.1
github.com/juju/fslock v0.0.0-20160525022230-4d5c94c67b4b
github.com/juruen/rmapi v0.0.25
github.com/poundifdef/go-remarkable2pdf v0.2.0
Expand Down Expand Up @@ -51,6 +50,7 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/ungerik/go-cairo v0.0.0-20240304075741-47de8851d267 // indirect
github.com/unidoc/freetype v0.2.3 // indirect
github.com/unidoc/pkcs7 v0.2.0 // indirect
github.com/unidoc/timestamp v0.0.0-20200412005513-91597fd3793a // indirect
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,12 @@ github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZH
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/joagonca/rmc-go v1.0.0 h1:oOIi2+If/UokYc+MohESW8MVEWV76zSZa9v57PlHt2I=
github.com/joagonca/rmc-go v1.0.0/go.mod h1:om1x4PQCiZFpLfEx1QrISz7v6eFozejiufmlNgtM90s=
github.com/joagonca/rmc-go v1.1.0 h1:LfT0VxGqosCJaBL0u0IL43RNfWLpX90P5QizlVkPRUU=
github.com/joagonca/rmc-go v1.1.0/go.mod h1:om1x4PQCiZFpLfEx1QrISz7v6eFozejiufmlNgtM90s=
github.com/joagonca/rmc-go v1.1.1 h1:2usXJnjuhmBdPPCDETk9r26CaqjVNZ5wDuVggCNOhMU=
github.com/joagonca/rmc-go v1.1.1/go.mod h1:om1x4PQCiZFpLfEx1QrISz7v6eFozejiufmlNgtM90s=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
Expand Down Expand Up @@ -224,6 +230,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ungerik/go-cairo v0.0.0-20240304075741-47de8851d267 h1:KA55kgg61iraQP4wSKIFRHwHIgDqim2Tvh8EXn7Udxw=
github.com/ungerik/go-cairo v0.0.0-20240304075741-47de8851d267/go.mod h1:yLTJg56omDJ+JVxZ5whpCrZgQdaSs+OBdFa+X6ViJcI=
github.com/unidoc/freetype v0.2.3 h1:uPqW+AY0vXN6K2tvtg8dMAtHTEvvHTN52b72XpZU+3I=
github.com/unidoc/freetype v0.2.3/go.mod h1:mJ/Q7JnqEoWtajJVrV6S1InbRv0K/fJerPB5SQs32KI=
github.com/unidoc/pkcs7 v0.0.0-20200411230602-d883fd70d1df/go.mod h1:UEzOZUEpJfDpywVJMUT8QiugqEZC29pDq7kdIZhWCr8=
Expand Down
6 changes: 5 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ func FromEnv() *Config {
cfg := Config{
Port: port,
StorageURL: uploadURL,
CloudHost: cloudHost,
CloudHost: cloudHost,
DataDir: dataDir,
JWTSecretKey: dk,
JWTRandom: jwtGenerated,
Expand Down Expand Up @@ -268,6 +268,10 @@ myScript hwr (needs a developer account):
%s
%s
%s override the language specified in myScript requests

V6 file format support:
Native rmc-go library with Cairo renderer is always enabled.
No configuration needed - v6 files are rendered in-process.
`,
envJWTSecretKey,
EnvStorageURL,
Expand Down
1 change: 1 addition & 0 deletions internal/storage/exporter/myarchive.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ func init() {
type MyArchive struct {
archive.Zip
PayloadReader io.ReadSeekCloser
V6PageData map[int][]byte // Raw v6 .rm data, indexed by page number
}

func (f *MyArchive) Close() {
Expand Down
68 changes: 68 additions & 0 deletions internal/storage/exporter/rmc_go.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package exporter

import (
"bytes"
"fmt"
"io"

rmc "github.com/joagonca/rmc-go"
)

// ExportV6ToPdfNative converts v6 .rm file to PDF using rmc-go library (in-process)
// This uses the Cairo renderer for native PDF generation
func ExportV6ToPdfNative(rmData []byte, output io.Writer) error {
opts := &rmc.Options{
UseLegacy: false, // Always use Cairo renderer (not Inkscape)
}

// Convert from bytes to PDF bytes
pdfData, err := rmc.ConvertFromBytes(rmData, rmc.FormatPDF, opts)
if err != nil {
return fmt.Errorf("failed to convert v6 rm to PDF: %w", err)
}

// Write to output
_, err = io.Copy(output, bytes.NewReader(pdfData))
if err != nil {
return fmt.Errorf("failed to write PDF output: %w", err)
}

return nil
}

// ExportV6ToSvgNative converts v6 .rm file to SVG using rmc-go library
func ExportV6ToSvgNative(rmData []byte, output io.Writer) error {
opts := &rmc.Options{}

svgData, err := rmc.ConvertFromBytes(rmData, rmc.FormatSVG, opts)
if err != nil {
return fmt.Errorf("failed to convert v6 rm to SVG: %w", err)
}

_, err = io.Copy(output, bytes.NewReader(svgData))
if err != nil {
return fmt.Errorf("failed to write SVG output: %w", err)
}

return nil
}

// ExportV6MultiPageToPdfNative converts multiple v6 .rm pages to a single PDF
func ExportV6MultiPageToPdfNative(pages [][]byte, output io.Writer) error {
if len(pages) == 0 {
return fmt.Errorf("no pages provided")
}

opts := &rmc.Options{
UseLegacy: false, // Use Cairo renderer
}

// Use rmc-go's multipage function
pdfData, err := rmc.ConvertMultipleFromBytes(pages, opts)
if err != nil {
return fmt.Errorf("failed to convert multiple v6 pages to PDF: %w", err)
}

_, err = io.Copy(output, bytes.NewReader(pdfData))
return err
}
101 changes: 101 additions & 0 deletions internal/storage/exporter/version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package exporter

import (
"bytes"
"fmt"
"io"
)

const (
// HeaderV3 is the header for version 3 .rm files
HeaderV3 = "reMarkable .lines file, version=3"
// HeaderV5 is the header for version 5 .rm files
HeaderV5 = "reMarkable .lines file, version=5"
// HeaderV6 is the header for version 6 .rm files (43 bytes with padding)
HeaderV6 = "reMarkable .lines file, version=6"
// HeaderSizeV6 is the exact size of v6 header
HeaderSizeV6 = 43
)

// RmVersion represents the version of a .rm file
type RmVersion int

const (
// VersionUnknown indicates the version could not be determined
VersionUnknown RmVersion = 0
// VersionV3 indicates version 3 format
VersionV3 RmVersion = 3
// VersionV5 indicates version 5 format
VersionV5 RmVersion = 5
// VersionV6 indicates version 6 format
VersionV6 RmVersion = 6
)

// String returns the string representation of the version
func (v RmVersion) String() string {
switch v {
case VersionV3:
return "v3"
case VersionV5:
return "v5"
case VersionV6:
return "v6"
default:
return "unknown"
}
}

// DetectRmVersion reads the header from .rm file to determine version
// The reader should be positioned at the start of the file
func DetectRmVersion(reader io.Reader) (RmVersion, error) {
// Read enough bytes to detect any version
// v6 header is 43 bytes, v3/v5 are shorter
headerBuf := make([]byte, HeaderSizeV6)
n, err := io.ReadAtLeast(reader, headerBuf, len(HeaderV3))
if err != nil && err != io.ErrUnexpectedEOF {
return VersionUnknown, fmt.Errorf("failed to read header: %w", err)
}

header := headerBuf[:n]

// Check v6 first (most recent, longest header)
if bytes.HasPrefix(header, []byte(HeaderV6)) {
return VersionV6, nil
}

// Check v5
if bytes.Contains(header, []byte("version=5")) {
return VersionV5, nil
}

// Check v3
if bytes.Contains(header, []byte("version=3")) {
return VersionV3, nil
}

return VersionUnknown, fmt.Errorf("unknown .rm file format")
}

// DetectRmVersionFromBytes detects version from byte slice
func DetectRmVersionFromBytes(data []byte) (RmVersion, error) {
return DetectRmVersion(bytes.NewReader(data))
}

// DetectArchiveVersion detects the .rm file version from an archive
// by examining the first page data
func DetectArchiveVersion(arch *MyArchive) (RmVersion, error) {
if len(arch.Pages) == 0 {
return VersionUnknown, fmt.Errorf("no pages in archive")
}

// Try to marshal first page and detect from header
if arch.Pages[0].Data != nil {
data, err := arch.Pages[0].Data.MarshalBinary()
if err != nil {
return VersionUnknown, fmt.Errorf("failed to marshal page data: %w", err)
}
return DetectRmVersion(bytes.NewReader(data))
}

return VersionUnknown, fmt.Errorf("no page data available")
}
Loading
Loading