diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 51979e86..5fd6f88c 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -35,6 +35,11 @@ jobs: cache: 'pnpm' cache-dependency-path: ui/pnpm-lock.yaml + - name: Install Cairo dependencies + run: | + sudo apt-get update + sudo apt-get install -y libcairo2-dev pkg-config + - name: Build run: make build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc7d26f0..c4071541 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,12 @@ jobs: version: 9 run_install: false - - name: Build + - name: Install Cairo dependencies + run: | + sudo apt-get update + sudo apt-get install -y libcairo2-dev pkg-config + + - name: Build run: make all - name: Release diff --git a/Dockerfile b/Dockerfile index 2ae0b5ae..45c0db39 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,14 +14,25 @@ RUN pnpm install && pnpm build FROM golang:bookworm AS gobuilder ARG VERSION WORKDIR /src + +# Install Cairo development dependencies +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/ +RUN go generate ./... && CGO_ENABLED=1 go build -tags cairo -ldflags "-s -w -X main.version=${VERSION}" -o rmfakecloud-docker ./cmd/rmfakecloud/ -FROM scratch +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 Cairo runtime libraries +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"] diff --git a/Dockerfile.make b/Dockerfile.make index 405d162f..8c109014 100644 --- a/Dockerfile.make +++ b/Dockerfile.make @@ -1,5 +1,12 @@ -FROM scratch +FROM debian:bookworm-slim EXPOSE 3000 + +# Install Cairo runtime libraries +RUN apt-get update && apt-get install -y \ + ca-certificates \ + libcairo2 \ + && rm -rf /var/lib/apt/lists/* + #ENV RMAPI_HWR_HMAC #ENV RM_SMTP_SERVER="" #ENV RM_SMTP_USERNAME="" diff --git a/Makefile b/Makefile index 5bacdfab..b93b9b99 100644 --- a/Makefile +++ b/Makefile @@ -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 = CGO_ENABLED=1 go build -tags cairo -ldflags $(LDFLAGS) -o $(@) $(CMD) +BUILD_CAIRO = $(BUILD) ASSETS = ui/dist GOFILES := $(shell find . -iname '*.go' ! -iname "*_test.go") GOFILES += $(ASSETS) @@ -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) @@ -35,13 +38,20 @@ $(OUT_DIR)/$(BINARY)-arm64:$(GOFILES) GOARCH=arm64 $(BUILD) $(OUT_DIR)/$(BINARY)-docker:$(GOFILES) - CGO_ENABLED=0 $(BUILD) + $(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 . run: $(ASSETS) - go run $(CMD) $(ARG) + go run -tags cairo $(CMD) $(ARG) $(ASSETS): $(UIFILES) ui/pnpm-lock.yaml #@cp ui/node_modules/pdfjs-dist/build/pdf.worker.js ui/public/ @@ -70,5 +80,5 @@ testui: #CI=true $(PNPM) test testgo: - go test ./... + go test -tags cairo ./... diff --git a/README.md b/README.md index d068f244..3fa8cef0 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,8 @@ See the [documentation](https://ddvk.github.io/rmfakecloud/remarkable/setup/) fo | 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 | | [Passcode (PIN) reset](https://ddvk.github.io/rmfakecloud/usage/passcode-reset/) | ✅ | reMarkable 1 / reMarkable 2 only | -| 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 @@ -50,6 +51,57 @@ See the [documentation](https://ddvk.github.io/rmfakecloud/remarkable/setup/) fo 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 diff --git a/docs/install/source.md b/docs/install/source.md index 3c3d22ef..79016274 100644 --- a/docs/install/source.md +++ b/docs/install/source.md @@ -10,6 +10,11 @@ To be able to compile from source, you'll need the following dependencies: * [pnpm](https://pnpm.io/) * [go](https://go.dev/) version 1.16 at least * make +* **Cairo graphics library** (required for PDF rendering with annotations) + - On Debian/Ubuntu: `sudo apt-get install libcairo2-dev pkg-config` + - On Fedora/RHEL: `sudo dnf install cairo-devel pkgconfig` + - On macOS: `brew install cairo pkg-config` + - On Arch Linux: `sudo pacman -S cairo pkgconf` Build ----- @@ -20,6 +25,8 @@ cd rmfakecloud make all ``` +**Note:** The build process requires Cairo to be installed, as it's used for rendering reMarkable annotations to PDF. The build uses the `-tags cairo` flag to enable Cairo support. If you encounter build errors related to Cairo, ensure the Cairo development libraries are properly installed on your system. + Installing ========== diff --git a/go.mod b/go.mod index 20ec771e..306449d6 100644 --- a/go.mod +++ b/go.mod @@ -1,37 +1,33 @@ module github.com/ddvk/rmfakecloud -go 1.23.3 - -toolchain go1.24.1 +go 1.25.1 require ( - github.com/danjacques/gofslock v0.0.0-20240212154529-d899e02bfe22 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/juruen/rmapi v0.0.25 + github.com/joagonca/rmc-go v1.1.1 + github.com/juju/fslock v0.0.0-20160525022230-4d5c94c67b4b github.com/mochi-mqtt/server/v2 v2.7.9 + github.com/pdfcpu/pdfcpu v0.11.1 github.com/poundifdef/go-remarkable2pdf v0.2.0 github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 github.com/sirupsen/logrus v1.9.3 - github.com/soheilhy/cmux v0.1.5 github.com/stretchr/testify v1.9.0 github.com/studio-b12/gowebdav v0.9.0 - github.com/unidoc/unipdf/v3 v3.56.0 - golang.org/x/crypto v0.36.0 + github.com/ungerik/go-cairo v0.0.0-20240304075741-47de8851d267 + golang.org/x/crypto v0.43.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/adrg/strutil v0.3.1 // indirect - github.com/adrg/sysfont v0.1.2 // indirect - github.com/adrg/xdg v0.4.0 // indirect github.com/bytedance/sonic v1.11.3 // indirect github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect github.com/chenzhuoyu/iasm v0.9.1 // indirect + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect @@ -40,32 +36,30 @@ require ( github.com/go-playground/validator/v10 v10.19.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/gorilla/i18n v0.0.0-20150820051429-8b358169da46 // indirect + github.com/hhrutter/lzw v1.0.0 // indirect + github.com/hhrutter/pkcs7 v0.2.0 // indirect + github.com/hhrutter/tiff v1.0.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/jung-kurt/gofpdf v1.16.2 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/pelletier/go-toml/v2 v2.2.0 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rs/xid v1.4.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // 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 - github.com/unidoc/unichart v0.3.0 // indirect - github.com/unidoc/unitype v0.4.0 // indirect golang.org/x/arch v0.7.0 // indirect - golang.org/x/image v0.18.0 // indirect - golang.org/x/net v0.38.0 // indirect + golang.org/x/image v0.32.0 // indirect + golang.org/x/net v0.45.0 // indirect golang.org/x/oauth2 v0.18.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/text v0.23.0 // indirect - golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index d307a7fc..2f662bac 100644 --- a/go.sum +++ b/go.sum @@ -33,14 +33,6 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/adrg/strutil v0.2.2/go.mod h1:EF2fjOFlGTepljfI+FzgTG13oXthR7ZAil9/aginnNQ= -github.com/adrg/strutil v0.3.1 h1:OLvSS7CSJO8lBii4YmBt8jiK9QOtB9CzCzwl4Ic/Fz4= -github.com/adrg/strutil v0.3.1/go.mod h1:8h90y18QLrs11IBffcGX3NW/GFBXCMcNg4M7H6MspPA= -github.com/adrg/sysfont v0.1.2 h1:MSU3KREM4RhsQ+7QgH7wPEPTgAgBIz0Hw6Nd4u7QgjE= -github.com/adrg/sysfont v0.1.2/go.mod h1:6d3l7/BSjX9VaeXWJt9fcrftFaD/t7l11xgSywCPZGk= -github.com/adrg/xdg v0.3.0/go.mod h1:7I2hH/IT30IsupOpKZ5ue7/qNi3CoKzD6tL3HwpaRMQ= -github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= -github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E= github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= @@ -58,9 +50,9 @@ github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWR github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/danjacques/gofslock v0.0.0-20240212154529-d899e02bfe22 h1:m+Fkk9QEMuV6Z1ithqqYogOHV7Pl6rMKe34NBTJTS/c= -github.com/danjacques/gofslock v0.0.0-20240212154529-d899e02bfe22/go.mod h1:jXqs4TJbb7Xtl0FwUgBaOXty8edb/61H37U4D9E5EQE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -146,31 +138,35 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/gorilla/i18n v0.0.0-20150820051429-8b358169da46 h1:N+R2A3fGIr5GucoRMu2xpqyQWQlfY31orbofBCdjMz8= -github.com/gorilla/i18n v0.0.0-20150820051429-8b358169da46/go.mod h1:2Yoiy15Cf7Q3NFwfaJquh7Mk1uGI09ytcD7CUhn8j7s= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= 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/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= +github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= +github.com/hhrutter/pkcs7 v0.2.0 h1:i4HN2XMbGQpZRnKBLsUwO3dSckzgX142TNqY/KfXg+I= +github.com/hhrutter/pkcs7 v0.2.0/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE= +github.com/hhrutter/tiff v1.0.2 h1:7H3FQQpKu/i5WaSChoD1nnJbGx4MxU5TlNqqpxw55z8= +github.com/hhrutter/tiff v1.0.2/go.mod h1:pcOeuK5loFUE7Y/WnzGw20YxUdnqjY1P0Jlcieb/cCw= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/jinzhu/copier v0.3.5 h1:GlvfUwHk62RokgqVNvYsku0TATCF7bAHVwEXoBh3iJg= github.com/jinzhu/copier v0.3.5/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= +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= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/juju/fslock v0.0.0-20160525022230-4d5c94c67b4b h1:FQ7+9fxhyp82ks9vAuyPzG0/vVbWwMwLJ+P6yJI5FN8= +github.com/juju/fslock v0.0.0-20160525022230-4d5c94c67b4b/go.mod h1:HMcgvsgd0Fjj4XXDkbjdmlbI505rUPBs6WBMYg2pXks= github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc= github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0= -github.com/juruen/rmapi v0.0.25 h1:9i9LhzWBtSKRuhgzLWK5X013kNzb+X5rd+0WM5eHbC0= -github.com/juruen/rmapi v0.0.25/go.mod h1:w3sRs3dEsPenlZJec2x1iy9EGeKzIAAMrPeZ+iBDEnA= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -182,6 +178,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mochi-mqtt/server/v2 v2.7.9 h1:y0g4vrSLAag7T07l2oCzOa/+nKVLoazKEWAArwqBNYI= github.com/mochi-mqtt/server/v2 v2.7.9/go.mod h1:lZD3j35AVNqJL5cezlnSkuG05c0FCHSsfAKSPBOSbqc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -189,12 +187,14 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/pdfcpu/pdfcpu v0.11.1 h1:htHBSkGH5jMKWC6e0sihBFbcKZ8vG1M67c8/dJxhjas= +github.com/pdfcpu/pdfcpu v0.11.1/go.mod h1:pP3aGga7pRvwFWAm9WwFvo+V68DfANi9kxSQYioNYcw= github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo= github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/poundifdef/go-remarkable2pdf v0.2.0 h1:WDRh/ZBkpEOLPLj3lVfoHrQpyVkOQv2A3XOpViRZ0mE= @@ -210,11 +210,8 @@ github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo= github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY= -github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= -github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -222,7 +219,6 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -236,19 +232,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/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= -github.com/unidoc/pkcs7 v0.2.0 h1:0Y0RJR5Zu7OuD+/l7bODXARn6b8Ev2G4A8lI4rzy9kg= -github.com/unidoc/pkcs7 v0.2.0/go.mod h1:UEzOZUEpJfDpywVJMUT8QiugqEZC29pDq7kdIZhWCr8= -github.com/unidoc/timestamp v0.0.0-20200412005513-91597fd3793a h1:RLtvUhe4DsUDl66m7MJ8OqBjq8jpWBXPK6/RKtqeTkc= -github.com/unidoc/timestamp v0.0.0-20200412005513-91597fd3793a/go.mod h1:j+qMWZVpZFTvDey3zxUkSgPJZEX33tDgU/QIA0IzCUw= -github.com/unidoc/unichart v0.3.0 h1:VX1j5yzhjrR3f2flC03Yat6/WF3h7Z+DLEvJLoTGhoc= -github.com/unidoc/unichart v0.3.0/go.mod h1:8JnLNKSOl8yQt1jXewNgYFHhFm5M6/ZiaydncFDpakA= -github.com/unidoc/unipdf/v3 v3.56.0 h1:15Lt+AZvELP03PH23ypV0y5reKZxCRKSq46SZg+vx6A= -github.com/unidoc/unipdf/v3 v3.56.0/go.mod h1:iBr/OsbLnJ49WhJlpfpYS3VmXrkTG05O7rKe9crppmc= -github.com/unidoc/unitype v0.4.0 h1:/TMZ3wgwfWWX64mU5x2O9no9UmoBqYCB089LYYqHyQQ= -github.com/unidoc/unitype v0.4.0/go.mod h1:HV5zuUeqMKA4QgYQq3KDlJY/P96XF90BQB+6czK6LVA= +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/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -268,8 +253,8 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -283,9 +268,8 @@ golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMk golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= -golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= -golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= +golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ= +golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -333,11 +317,10 @@ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= +golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -360,7 +343,6 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -385,16 +367,13 @@ golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220731174439-a90be440212d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -402,11 +381,10 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -456,8 +434,6 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= -golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= @@ -538,11 +514,12 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/archive/reader.go b/internal/archive/reader.go new file mode 100644 index 00000000..b90dd5df --- /dev/null +++ b/internal/archive/reader.go @@ -0,0 +1,347 @@ +package archive + +import ( + "archive/zip" + "bufio" + "encoding/json" + "errors" + "io" + "path" + "path/filepath" + "strconv" + "strings" + + "github.com/ddvk/rmfakecloud/internal/encoding/rm" + "github.com/google/uuid" + log "github.com/sirupsen/logrus" +) + +// Read fills a Zip parsing a Remarkable archive file. +func (z *Zip) Read(r io.ReaderAt, size int64) error { + zr, err := zip.NewReader(r, size) + if err != nil { + return err + } + + // reading content first because it contains the number of pages + if err := z.readContent(zr); err != nil { + return err + } + + if err := z.readPayload(zr); err != nil { + return err + } + + //uploading and then downloading a file results in 0 pages + if z.Content.PageCount <= 0 { + log.Warn("PageCount is 0") + return nil + } + + if err := z.readMetadata(zr); err != nil { + return err + } + + if err := z.readPagedata(zr); err != nil { + return err + } + + if err := z.readData(zr); err != nil { + return err + } + + if err := z.readThumbnails(zr); err != nil { + return err + } + + return nil +} + +// readContent reads the .content file contained in an archive and the UUID +func (z *Zip) readContent(zr *zip.Reader) error { + files, err := zipExtFinder(zr, ".content") + if err != nil { + return err + } + + if len(files) != 1 { + return errors.New("archive does not contain a unique content file") + } + + contentFile := files[0] + file, err := contentFile.Open() + if err != nil { + return err + } + defer file.Close() + + bytes, err := io.ReadAll(file) + if err != nil { + return err + } + + if err = json.Unmarshal(bytes, &z.Content); err != nil { + return err + } + p := contentFile.FileInfo().Name() + id := docPathToName(p) + z.UUID = id + + redirectedCount := len(z.Content.RedirectionMap) + pagesCount := len(z.Content.Pages) + if redirectedCount > 0 { + z.pageMap = make(map[string]int) + z.Pages = make([]Page, redirectedCount) + for index, docPage := range z.Content.RedirectionMap { + if index >= pagesCount { + log.Warn("redirection > pages") + break + } + pageUUID := z.Content.Pages[index] + z.pageMap[pageUUID] = index + z.Pages[index].DocPage = docPage + } + + } else if pagesCount > 0 { + z.pageMap = make(map[string]int) + z.Pages = make([]Page, pagesCount) + for index, pageUUID := range z.Content.Pages { + z.pageMap[pageUUID] = index + z.Pages[index].DocPage = index + } + } else { + // instantiate the slice of pages + z.Pages = make([]Page, z.Content.PageCount) + } + return nil +} + +// readPagedata reads the .pagedata file contained in an archive +// and iterate to gather which template was used for each page. +func (z *Zip) readPagedata(zr *zip.Reader) error { + files, err := zipExtFinder(zr, ".pagedata") + if err != nil { + return err + } + + if len(files) != 1 { + return errors.New("archive does not contain a unique pagedata file") + } + + file, err := files[0].Open() + if err != nil { + return err + } + defer file.Close() + + // iterate pagedata file lines + sc := bufio.NewScanner(file) + var i int = 0 + for sc.Scan() { + line := sc.Text() + z.Pages[i].Pagedata = line + i++ + } + + if err := sc.Err(); err != nil { + return err + } + + return nil +} + +// readPayload tries to extract the payload from an archive if it exists. +func (z *Zip) readPayload(zr *zip.Reader) error { + ext := z.Content.FileType + files, err := zipExtFinder(zr, "."+ext) + if err != nil { + return err + } + + // return if not found + if len(files) != 1 { + return nil + } + + file, err := files[0].Open() + if err != nil { + return err + } + defer file.Close() + + z.Payload, err = io.ReadAll(file) + if err != nil { + return err + } + + return nil +} + +// readData extracts existing .rm files from an archive. +func (z *Zip) readData(zr *zip.Reader) error { + files, err := zipExtFinder(zr, ".rm") + if err != nil { + return err + } + + for _, file := range files { + name, _ := splitExt(file.FileInfo().Name()) + + idx, err := z.pageIndex(name) + if err != nil { + return err + } + + if len(z.Pages) <= idx { + return errors.New("page not found") + } + + r, err := file.Open() + if err != nil { + return err + } + + bytes, err := io.ReadAll(r) + if err != nil { + return err + } + + z.Pages[idx].Data = rm.New() + err = z.Pages[idx].Data.UnmarshalBinary(bytes) + if err != nil { + return err + } + } + + return nil +} + +// readThumbnails extracts existing thumbnails from an archive. +func (z *Zip) readThumbnails(zr *zip.Reader) error { + files, err := zipExtFinder(zr, ".jpg") + if err != nil { + return err + } + + for _, file := range files { + name, _ := splitExt(file.FileInfo().Name()) + + idx, err := strconv.Atoi(name) + if err != nil { + return errors.New("error in .jpg filename") + } + + if len(z.Pages) <= idx { + return errors.New("page not found") + } + + r, err := file.Open() + if err != nil { + return err + } + + z.Pages[idx].Thumbnail, err = io.ReadAll(r) + if err != nil { + return err + } + } + + return nil +} + +func (z *Zip) pageIndex(namePart string) (idx int, err error) { + idx, err = strconv.Atoi(namePart) + if err == nil { + return idx, nil + } + _, err = uuid.Parse(namePart) + if err != nil { + return -1, errors.New("neither int nor uuid page") + } + + if z.pageMap == nil { + return -1, errors.New("no uuid pagemap") + } + var ok bool + idx, ok = z.pageMap[namePart] + if !ok { + log.Warn("Page not found in map: ", namePart) + } + + return +} + +// readMetadata extracts existing .json metadata files from an archive. +func (z *Zip) readMetadata(zr *zip.Reader) error { + files, err := zipExtFinder(zr, ".json") + if err != nil { + return err + } + + for _, file := range files { + name, _ := splitExt(file.FileInfo().Name()) + + // name is 0-metadata.json or uuid-metadata + namePart := strings.TrimSuffix(name, "-metadata") + idx, err := z.pageIndex(namePart) + if err != nil { + return err + } + + if len(z.Pages) <= idx { + return errors.New("page not found") + } + + r, err := file.Open() + if err != nil { + return err + } + + bytes, err := io.ReadAll(r) + if err != nil { + return err + } + + err = json.Unmarshal(bytes, &z.Pages[idx].Metadata) + if err != nil { + return err + } + } + + return nil +} + +// splitExt splits the extension from a filename +func splitExt(name string) (string, string) { + ext := filepath.Ext(name) + return name[0 : len(name)-len(ext)], ext +} + +// zipExtFinder searches for a file matching the substr pattern +// in a zip file. +func zipExtFinder(zr *zip.Reader, ext string) ([]*zip.File, error) { + var files []*zip.File + + for _, file := range zr.File { + parentFolderName := path.Dir(file.FileHeader.Name) + if strings.HasSuffix(parentFolderName, ".highlights") { + continue + } + filename := file.FileInfo().Name() + if _, e := splitExt(filename); e == ext { + files = append(files, file) + } + } + + return files, nil +} + +// docPathToName extracts document name from path (simple version) +func docPathToName(p string) string { + name := filepath.Base(p) + ext := filepath.Ext(name) + if ext != "" { + name = name[0 : len(name)-len(ext)] + } + return name +} diff --git a/internal/archive/types.go b/internal/archive/types.go new file mode 100644 index 00000000..d8a079f4 --- /dev/null +++ b/internal/archive/types.go @@ -0,0 +1,160 @@ +// Package archive contains types for parsing reMarkable archive files +package archive + +import ( + "github.com/ddvk/rmfakecloud/internal/encoding/rm" +) + +// Set the default pagedata template to Blank +const defaultPagadata string = "Blank" + +// Zip represents an entire Remarkable archive file. +type Zip struct { + Content Content + Pages []Page + Payload []byte + UUID string + pageMap map[string]int +} + +// NewZip creates a File with sane defaults. +func NewZip() *Zip { + content := Content{ + DummyDocument: false, + ExtraMetadata: ExtraMetadata{ + LastBrushColor: "Black", + LastBrushThicknessScale: "2", + LastColor: "Black", + LastEraserThicknessScale: "2", + LastEraserTool: "Eraser", + LastPen: "Ballpoint", + LastPenColor: "Black", + LastPenThicknessScale: "2", + LastPencil: "SharpPencil", + LastPencilColor: "Black", + LastPencilThicknessScale: "2", + LastTool: "SharpPencil", + ThicknessScale: "2", + LastFinelinerv2Size: "1", + }, + FileType: "", + FontName: "", + LastOpenedPage: 0, + LineHeight: -1, + Margins: 100, + Orientation: "portrait", + PageCount: 0, + Pages: []string{}, + TextScale: 1, + Transform: Transform{ + M11: 1, + M12: 0, + M13: 0, + M21: 0, + M22: 1, + M23: 0, + M31: 0, + M32: 0, + M33: 1, + }, + } + + return &Zip{ + Content: content, + } +} + +// A Page represents a note page. +type Page struct { + // Data is the rm binary encoded file representing the drawn content + Data *rm.Rm + // Metadata is a json file containing information about layers + Metadata Metadata + // Thumbnail is a small image of the overall page + Thumbnail []byte + // Pagedata contains the name of the selected background template + Pagedata string + // page number of the underlying document + DocPage int +} + +// Metadata represents the structure of a .metadata json file associated to a page. +type Metadata struct { + Layers []Layer `json:"layers"` +} + +// Layers is a struct contained into a Metadata struct. +type Layer struct { + Name string `json:"name"` +} + +// Content represents the structure of a .content json file. +type Content struct { + DummyDocument bool `json:"dummyDocument"` + ExtraMetadata ExtraMetadata `json:"extraMetadata"` + + // FileType is "pdf", "epub" or empty for a simple note + FileType string `json:"fileType"` + FontName string `json:"fontName"` + LastOpenedPage int `json:"lastOpenedPage"` + LineHeight int `json:"lineHeight"` + Margins int `json:"margins"` + // Orientation can take "portrait" or "landscape". + Orientation string `json:"orientation"` + PageCount int `json:"pageCount"` + // Pages is a list of page IDs + Pages []string `json:"pages"` + Tags []string `json:"pageTags"` + RedirectionMap []int `json:"redirectionPageMap"` + TextScale int `json:"textScale"` + + Transform Transform `json:"transform"` +} + +// ExtraMetadata is a struct contained into a Content struct. +type ExtraMetadata struct { + LastBrushColor string `json:"LastBrushColor"` + LastBrushThicknessScale string `json:"LastBrushThicknessScale"` + LastColor string `json:"LastColor"` + LastEraserThicknessScale string `json:"LastEraserThicknessScale"` + LastEraserTool string `json:"LastEraserTool"` + LastPen string `json:"LastPen"` + LastPenColor string `json:"LastPenColor"` + LastPenThicknessScale string `json:"LastPenThicknessScale"` + LastPencil string `json:"LastPencil"` + LastPencilColor string `json:"LastPencilColor"` + LastPencilThicknessScale string `json:"LastPencilThicknessScale"` + LastTool string `json:"LastTool"` + ThicknessScale string `json:"ThicknessScale"` + LastFinelinerv2Size string `json:"LastFinelinerv2Size"` +} + +// Transform is a struct contained into a Content struct. +type Transform struct { + M11 float32 `json:"m11"` + M12 float32 `json:"m12"` + M13 float32 `json:"m13"` + M21 float32 `json:"m21"` + M22 float32 `json:"m22"` + M23 float32 `json:"m23"` + M31 float32 `json:"m31"` + M32 float32 `json:"m32"` + M33 float32 `json:"m33"` +} + +// MetadataFile content +type MetadataFile struct { + DocName string `json:"visibleName"` + CollectionType string `json:"type"` + Parent string `json:"parent"` + //LastModified in milliseconds + LastModified string `json:"lastModified"` + LastOpened string `json:"lastOpened"` + LastOpenedPage int `json:"lastOpenedPage"` + Version int `json:"version"` + Pinned bool `json:"pinned"` + Synced bool `json:"synced"` + Modified bool `json:"modified"` + Deleted bool `json:"deleted"` + MetadataModified bool `json:"metadatamodified"` +} diff --git a/internal/config/config.go b/internal/config/config.go index 446b8b3a..90e997ac 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -320,6 +320,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, diff --git a/internal/encoding/rm/marshal.go b/internal/encoding/rm/marshal.go new file mode 100644 index 00000000..c678eb5d --- /dev/null +++ b/internal/encoding/rm/marshal.go @@ -0,0 +1,8 @@ +package rm + +// MarshalBinary implements encoding.MarshalBinary for +// transforming a Rm page into bytes +// TODO +func (rm *Rm) MarshalBinary() (data []byte, err error) { + return nil, nil +} diff --git a/internal/encoding/rm/rm.go b/internal/encoding/rm/rm.go new file mode 100644 index 00000000..f30cd3e2 --- /dev/null +++ b/internal/encoding/rm/rm.go @@ -0,0 +1,180 @@ +// Package rm provides primitives for encoding and decoding +// the .rm format which is a proprietary format created by +// Remarkable to store the data of a drawing made with the device. +// +// Axel Huebl has made a great job of understanding this binary format and +// has written an excellent blog post that helped a lot for writting this package. +// https://plasma.ninja/blog/devices/remarkable/binary/format/2017/12/26/reMarkable-lines-file-format.html +// As well, he has its own implementation of this decoder in C++ at this repository. +// https://github.com/ax3l/lines-are-beautiful +// +// To mention that the format has since evolve to a new version labeled as v3 in the +// header. This implementation is targeting this new version. +// +// As Ben Johnson says, "In the Go standard library, we use the term encoding +// and marshaling for two separate but related ideas. An encoder in Go is an object +// that applies structure to a stream of bytes while marshaling refers +// to applying structure to bounded, in-memory bytes." +// https://medium.com/go-walkthrough/go-walkthrough-encoding-package-bc5e912232d +// +// We will follow this convention and refer to marshaling for this encoder/decoder +// because we want to transform a .rm binary into a bounded in-memory representation +// of a .rm file. +// +// To try to be as idiomatic as possible, this package implements the two following interfaces +// of the default encoding package (https://golang.org/pkg/encoding/). +// - BinaryMarshaler +// - BinaryUnmarshaler +// +// The scope of this package is defined as just the encoding/decoding of the .rm format. +// It will only deal with bytes and not files (one must take care of unzipping the archive +// taken from the device, extracting and providing the content of .rm file as bytes). +// +// This package won't be used for retrieving metadata or attached PDF, ePub files. +package rm + +import ( + "fmt" + "strings" +) + +// Version defines the version number of a remarkable note. +type Version int + +const ( + V3 Version = iota + V5 +) + +// 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 " + HeaderLen = 43 +) + +// Width and Height of the device in pixels. +const ( + Width int = 1404 + Height int = 1872 +) + +// BrushColor defines the 3 colors of the brush. +type BrushColor uint32 + +// Mapping of the three colors. +const ( + Black BrushColor = 0 + Grey BrushColor = 1 + White BrushColor = 2 +) + +// BrushType respresents the type of brush. +// +// The different types of brush are explained here: +// https://blog.remarkable.com/how-to-find-your-perfect-writing-instrument-for-notetaking-on-remarkable-f53c8faeab77 +type BrushType uint32 + +// Mappings for brush types. +const ( + BallPoint BrushType = 2 + Marker BrushType = 3 + Fineliner BrushType = 4 + SharpPencil BrushType = 7 + TiltPencil BrushType = 1 + Brush BrushType = 0 + Highlighter BrushType = 5 + Eraser BrushType = 6 + EraseArea BrushType = 8 + + // v5 brings new brush type IDs + BallPointV5 BrushType = 15 + MarkerV5 BrushType = 16 + FinelinerV5 BrushType = 17 + SharpPencilV5 BrushType = 13 + TiltPencilV5 BrushType = 14 + BrushV5 BrushType = 12 + HighlighterV5 BrushType = 18 +) + +// BrushSize represents the base brush sizes. +type BrushSize float32 + +// 3 different brush sizes are noticed. +const ( + Small BrushSize = 1.875 + Medium BrushSize = 2.0 + Large BrushSize = 2.125 +) + +// A Rm represents an entire .rm file +// and is composed of layers. +type Rm struct { + Version Version + Layers []Layer +} + +// A Layer contains lines. +type Layer struct { + Lines []Line +} + +// A Line is composed of points. +type Line struct { + BrushType BrushType + BrushColor BrushColor + Padding uint32 + Unknown float32 + BrushSize BrushSize + Points []Point +} + +// A Point has coordinates. +type Point struct { + X float32 + Y float32 + Speed float32 + Direction float32 + Width float32 + Pressure float32 +} + +// New helps creating an empty Rm page. +// By mashaling an empty Rm page and exporting it +// to the device, we should generate an empty page +// as if it were created using the device itself. +// TODO +func New() *Rm { + return &Rm{} +} + +// String implements the fmt.Stringer interface +// The aim is to create a textual representation of a page as in the following image. +// https://plasma.ninja/blog/assets/reMarkable/2017_12_21_reMarkableAll.png +// TODO +func (rm Rm) String() string { + var o strings.Builder + + fmt.Fprintf(&o, "no of layers: %d\n", len(rm.Layers)) + for i, layer := range rm.Layers { + fmt.Fprintf(&o, "layer %d\n", i) + fmt.Fprintf(&o, " nb of lines: %d\n", len(layer.Lines)) + for j, line := range layer.Lines { + fmt.Fprintf(&o, " line %d\n", j) + fmt.Fprintf(&o, " brush type: %d\n", line.BrushType) + fmt.Fprintf(&o, " brush color: %d\n", line.BrushColor) + fmt.Fprintf(&o, " padding: %d\n", line.Padding) + fmt.Fprintf(&o, " brush size: %f\n", line.BrushSize) + fmt.Fprintf(&o, " nb of points: %d\n", len(line.Points)) + for k, point := range line.Points { + fmt.Fprintf(&o, " point %d\n", k) + fmt.Fprintf(&o, " coords: %f, %f\n", point.X, point.Y) + fmt.Fprintf(&o, " speed: %f\n", point.Speed) + fmt.Fprintf(&o, " direction: %f\n", point.Direction) + fmt.Fprintf(&o, " width: %f\n", point.Width) + fmt.Fprintf(&o, " pressure: %f\n", point.Pressure) + } + } + } + return o.String() +} diff --git a/internal/encoding/rm/unmarshal.go b/internal/encoding/rm/unmarshal.go new file mode 100644 index 00000000..e8198006 --- /dev/null +++ b/internal/encoding/rm/unmarshal.go @@ -0,0 +1,160 @@ +package rm + +import ( + "bytes" + "encoding/binary" + "fmt" +) + +// UnmarshalBinary implements encoding.UnmarshalBinary for +// transforming bytes into a Rm page +func (rm *Rm) UnmarshalBinary(data []byte) error { + r := newReader(data) + if err := r.checkHeader(); err != nil { + return err + } + rm.Version = r.version + + nbLayers, err := r.readNumber() + if err != nil { + return err + } + + rm.Layers = make([]Layer, nbLayers) + for i := uint32(0); i < nbLayers; i++ { + nbLines, err := r.readNumber() + if err != nil { + return err + } + + rm.Layers[i].Lines = make([]Line, nbLines) + for j := uint32(0); j < nbLines; j++ { + line, err := r.readLine() + if err != nil { + return err + } + rm.Layers[i].Lines[j] = line + } + } + + return nil +} + +type reader struct { + bytes.Reader + version Version +} + +func newReader(data []byte) reader { + br := bytes.NewReader(data) + + // we set V5 as default but the real value is + // analysed when checking the header + return reader{*br, V5} +} + +func (r *reader) checkHeader() error { + buf := make([]byte, HeaderLen) + + n, err := r.Read(buf) + if err != nil { + return err + } + + if n != HeaderLen { + return fmt.Errorf("Wrong header size") + } + + switch string(buf) { + case HeaderV5: + r.version = V5 + case HeaderV3: + r.version = V3 + default: + return fmt.Errorf("Unknown header") + } + + return nil +} + +func (r *reader) readNumber() (uint32, error) { + var nb uint32 + if err := binary.Read(r, binary.LittleEndian, &nb); err != nil { + return 0, fmt.Errorf("Wrong number read") + } + return nb, nil +} + +func (r *reader) readLine() (Line, error) { + var line Line + + if err := binary.Read(r, binary.LittleEndian, &line.BrushType); err != nil { + return line, fmt.Errorf("Failed to read line") + } + + if err := binary.Read(r, binary.LittleEndian, &line.BrushColor); err != nil { + return line, fmt.Errorf("Failed to read line") + } + + if err := binary.Read(r, binary.LittleEndian, &line.Padding); err != nil { + return line, fmt.Errorf("Failed to read line") + } + + if err := binary.Read(r, binary.LittleEndian, &line.BrushSize); err != nil { + return line, fmt.Errorf("Failed to read line") + } + + // this new attribute has been added in v5 + if r.version == V5 { + if err := binary.Read(r, binary.LittleEndian, &line.Unknown); err != nil { + return line, fmt.Errorf("Failed to read line") + } + } + + nbPoints, err := r.readNumber() + if err != nil { + return line, err + } + + if nbPoints == 0 { + return line, nil + } + + line.Points = make([]Point, nbPoints) + + for i := uint32(0); i < nbPoints; i++ { + p, err := r.readPoint() + if err != nil { + return line, err + } + + line.Points[i] = p + } + + return line, nil +} + +func (r *reader) readPoint() (Point, error) { + var point Point + + if err := binary.Read(r, binary.LittleEndian, &point.X); err != nil { + return point, fmt.Errorf("Failed to read point") + } + if err := binary.Read(r, binary.LittleEndian, &point.Y); err != nil { + return point, fmt.Errorf("Failed to read point") + } + if err := binary.Read(r, binary.LittleEndian, &point.Speed); err != nil { + return point, fmt.Errorf("Failed to read point") + } + if err := binary.Read(r, binary.LittleEndian, &point.Direction); err != nil { + return point, fmt.Errorf("Failed to read point") + } + if err := binary.Read(r, binary.LittleEndian, &point.Width); err != nil { + return point, fmt.Errorf("Failed to read point") + } + if err := binary.Read(r, binary.LittleEndian, &point.Pressure); err != nil { + return point, fmt.Errorf("Failed to read point") + } + + return point, nil +} diff --git a/internal/storage/exporter/license.go b/internal/storage/exporter/license.go deleted file mode 100644 index 6092c503..00000000 --- a/internal/storage/exporter/license.go +++ /dev/null @@ -1,21 +0,0 @@ -package exporter - -import ( - "time" - //blah - _ "unsafe" - - "github.com/unidoc/unipdf/v3/common/license" -) - -//go:linkname licenseKey github.com/unidoc/unipdf/v3/internal/license._gbdb -var licenseKey *license.LicenseKey - -func init() { - lk := license.LicenseKey{} - lk.CustomerName = "community" - lk.Tier = license.LicenseTierCommunity - lk.CreatedAt = time.Now().UTC() - lk.CreatedAtInt = lk.CreatedAt.Unix() - licenseKey = &lk -} diff --git a/internal/storage/exporter/myarchive.go b/internal/storage/exporter/myarchive.go index 0c681830..db62382d 100644 --- a/internal/storage/exporter/myarchive.go +++ b/internal/storage/exporter/myarchive.go @@ -3,19 +3,14 @@ package exporter import ( "io" - "github.com/juruen/rmapi/archive" - "github.com/juruen/rmapi/log" + "github.com/ddvk/rmfakecloud/internal/archive" ) -// rmapi's logging stuff -func init() { - log.InitLog() -} - // MyArchive but having the payload reader type MyArchive struct { archive.Zip PayloadReader io.ReadSeekCloser + V6PageData map[int][]byte // Raw v6 .rm data, indexed by page number } func (f *MyArchive) Close() { diff --git a/internal/storage/exporter/pdf.go b/internal/storage/exporter/pdf.go deleted file mode 100644 index ac7bd46b..00000000 --- a/internal/storage/exporter/pdf.go +++ /dev/null @@ -1,237 +0,0 @@ -package exporter - -import ( - "errors" - "fmt" - "io" - - "github.com/juruen/rmapi/encoding/rm" - "github.com/sirupsen/logrus" - "github.com/unidoc/unipdf/v3/annotator" - "github.com/unidoc/unipdf/v3/contentstream" - "github.com/unidoc/unipdf/v3/contentstream/draw" - "github.com/unidoc/unipdf/v3/core" - "github.com/unidoc/unipdf/v3/creator" - pdf "github.com/unidoc/unipdf/v3/model" -) - -const ( - DeviceWidth = 1404 - DeviceHeight = 1872 -) - -var rmPageSize = creator.PageSize{445, 594} - -type PdfGenerator struct { - options PdfGeneratorOptions - pdfReader *pdf.PdfReader - template bool -} - -type PdfGeneratorOptions struct { - AddPageNumbers bool - AllPages bool - AnnotationsOnly bool //export the annotations without the background/pdf -} - -func normalized(p1 rm.Point, ratioX float64) (float64, float64) { - return float64(p1.X) * ratioX, float64(p1.Y) * ratioX -} - -func (p *PdfGenerator) Generate(zip *MyArchive, output io.Writer, options PdfGeneratorOptions) (err error) { - - p.options = options - - if len(zip.Pages) == 0 { - if zip.PayloadReader != nil { - _, err := io.Copy(output, zip.PayloadReader) - return err - } - - return errors.New("the document has no pages") - } - - if err = p.initBackgroundPages(zip.PayloadReader); err != nil { - return err - } - - c := creator.New() - if p.template { - // use the standard page size - c.SetPageSize(rmPageSize) - } - - if p.pdfReader != nil && p.options.AllPages { - logrus.Info("generating all pages") - outlines := p.pdfReader.GetOutlineTree() - c.SetOutlineTree(outlines) - } - - for i, pageAnnotations := range zip.Pages { - hasContent := pageAnnotations.Data != nil - - // do not add a page when there are no annotations - if !p.options.AllPages && !hasContent { - continue - } - - page, err := p.addBackgroundPage(c, i+1) - if err != nil { - 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 { - logrus.Fatal("page is null") - } - - if err != nil { - return err - } - if !hasContent { - continue - } - - contentCreator := contentstream.NewContentCreator() - contentCreator.Add_q() - - for _, layer := range pageAnnotations.Data.Layers { - for _, line := range layer.Lines { - if len(line.Points) < 1 { - continue - } - if line.BrushType == rm.Eraser || line.BrushType == rm.EraseArea { - continue - } - - if line.BrushType == rm.HighlighterV5 { - last := len(line.Points) - 1 - x1, y1 := normalized(line.Points[0], scale) - x2, _ := normalized(line.Points[last], scale) - // make horizontal lines only, use y1 - width := scale * 30 - 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 - lineDef.Opacity = 0.5 - lineDef.LineWidth = width - ann, err := annotator.CreateLineAnnotation(lineDef) - if err != nil { - return err - } - page.AddAnnotation(ann) - } else { - path := draw.NewPath() - for i := 0; i < len(line.Points); i++ { - x1, y1 := normalized(line.Points[i], scale) - path = path.AppendPoint(draw.NewPoint(x1, c.Height()-y1)) - } - - contentCreator.Add_w(float64(line.BrushSize / 10)) - - switch line.BrushColor { - case rm.Black: - contentCreator.Add_rg(1.0, 1.0, 1.0) - case rm.White: - contentCreator.Add_rg(0.0, 0.0, 0.0) - case rm.Grey: - contentCreator.Add_rg(0.8, 0.8, 0.8) - } - - //TODO: use bezier - draw.DrawPathWithCreator(path, contentCreator) - - contentCreator.Add_S() - } - } - } - contentCreator.Add_Q() - drawingOperations := contentCreator.Operations().String() - pageContentStreams, err := page.GetAllContentStreams() - if err != nil { - return err - } - //hack: wrap the page content in a context to prevent transformation matrix misalignment - wrapper := []string{"q", pageContentStreams, "Q", drawingOperations} - page.SetContentStreams(wrapper, core.NewFlateEncoder()) - } - - return c.Write(output) -} - -func (p *PdfGenerator) initBackgroundPages(r io.ReadSeeker) error { - if r != nil { - pdfReader, err := pdf.NewPdfReader(r) - if err != nil { - return err - } - - encrypted, err := pdfReader.IsEncrypted() - if err != nil { - return nil - } - if encrypted { - valid, err := pdfReader.Decrypt([]byte("")) - if err != nil { - return err - } - if !valid { - return fmt.Errorf("cannot decrypt") - } - - } - - p.pdfReader = pdfReader - p.template = false - return nil - } - - logrus.Info("template") - p.template = true - return nil -} - -func (p *PdfGenerator) addBackgroundPage(c *creator.Creator, pageNum int) (*pdf.PdfPage, error) { - var page *pdf.PdfPage - - if !p.template && !p.options.AnnotationsOnly { - tmpPage, err := p.pdfReader.GetPage(pageNum) - if err != nil { - return nil, err - } - mbox, err := tmpPage.GetMediaBox() - if err != nil { - return nil, err - } - - // TODO: adjust the page if cropped - pageHeight := mbox.Ury - mbox.Lly - pageWidth := mbox.Urx - mbox.Llx - // use the pdf's page size - c.SetPageSize(creator.PageSize{pageWidth, pageHeight}) - c.AddPage(tmpPage) - page = tmpPage - } else { - page = c.NewPage() - } - - if p.options.AddPageNumbers { - c.DrawFooter(func(block *creator.Block, args creator.FooterFunctionArgs) { - p := c.NewParagraph(fmt.Sprintf("%d", args.PageNum)) - p.SetFontSize(8) - w := block.Width() - 20 - h := block.Height() - 10 - p.SetPos(w, h) - block.Draw(p) - }) - } - return page, nil -} diff --git a/internal/storage/exporter/pdf_cairo.go b/internal/storage/exporter/pdf_cairo.go new file mode 100644 index 00000000..6fba2018 --- /dev/null +++ b/internal/storage/exporter/pdf_cairo.go @@ -0,0 +1,393 @@ +//go:build cairo + +package exporter + +import ( + "bytes" + "fmt" + "io" + "os" + "unsafe" + + "github.com/ddvk/rmfakecloud/internal/encoding/rm" + "github.com/pdfcpu/pdfcpu/pkg/api" + "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model" + "github.com/sirupsen/logrus" + "github.com/ungerik/go-cairo" +) + +/* +#cgo pkg-config: cairo +#include +#include +#include +*/ +import "C" + +const ( + DeviceWidth = 1404 + DeviceHeight = 1872 +) + +// rmPageSize is the default page size for blank templates (in PDF points: 1/72 inch) +var rmPageSize = struct{ Width, Height float64 }{445, 594} + +type PdfGenerator struct { + options PdfGeneratorOptions + backgroundPDF []byte + template bool +} + +type PdfGeneratorOptions struct { + AddPageNumbers bool + AllPages bool + AnnotationsOnly bool //export the annotations without the background/pdf +} + +func normalized(p1 rm.Point, scale float64) (float64, float64) { + return float64(p1.X) * scale, float64(p1.Y) * scale +} + +// setPDFPageSize sets the size for the current page in a PDF surface +func setPDFPageSize(surface *cairo.Surface, width, height float64) { + surfacePtr, _ := surface.Native() + C.cairo_pdf_surface_set_size((*C.cairo_surface_t)(unsafe.Pointer(surfacePtr)), C.double(width), C.double(height)) +} + +func (p *PdfGenerator) Generate(zip *MyArchive, output io.Writer, options PdfGeneratorOptions) error { + p.options = options + + if len(zip.Pages) == 0 { + if zip.PayloadReader != nil { + _, err := io.Copy(output, zip.PayloadReader) + return err + } + return fmt.Errorf("the document has no pages") + } + + if err := p.initBackgroundPages(zip.PayloadReader); err != nil { + return err + } + + // If we have a background PDF and not annotations-only mode, we need a two-step process + if p.backgroundPDF != nil && !p.options.AnnotationsOnly { + return p.generateWithBackground(zip, output) + } + + // Otherwise, simple case: just annotations or blank pages + return p.generateAnnotationsOnly(zip, output) +} + +func (p *PdfGenerator) generateAnnotationsOnly(zip *MyArchive, output io.Writer) error { + // Create a temporary file for PDF output (Cairo requires a file path) + tmpFile, err := os.CreateTemp("", "rmfakecloud-annotations-*.pdf") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + tmpPath := tmpFile.Name() + tmpFile.Close() + defer os.Remove(tmpPath) + + // Determine first page dimensions + var firstWidth, firstHeight float64 + if p.template { + firstWidth, firstHeight = rmPageSize.Width, rmPageSize.Height + } else { + // TODO: Get dimensions from background PDF + firstWidth, firstHeight = rmPageSize.Width, rmPageSize.Height + } + + // Create PDF surface + pdfSurface := cairo.NewPDFSurface(tmpPath, firstWidth, firstHeight, cairo.PDF_VERSION_1_5) + defer pdfSurface.Finish() + + pageCount := 0 + for _, pageAnnotations := range zip.Pages { + hasContent := pageAnnotations.Data != nil + + // Skip pages without content unless AllPages is set + if !p.options.AllPages && !hasContent { + continue + } + + pageCount++ + + // Set page size (for pages after the first) + if pageCount > 1 { + var pageWidth, pageHeight float64 + if p.template { + pageWidth, pageHeight = rmPageSize.Width, rmPageSize.Height + } else { + // TODO: Get dimensions from background PDF page + pageWidth, pageHeight = rmPageSize.Width, rmPageSize.Height + } + setPDFPageSize(pdfSurface, pageWidth, pageHeight) + } + + // Calculate scale + pageWidth := firstWidth + pageHeight := firstHeight + ratio := pageHeight / pageWidth + + var scale float64 + if ratio < 1.33 { + scale = pageWidth / DeviceWidth + } else { + scale = pageHeight / DeviceHeight + } + + // Draw annotations if present + if hasContent { + if err := p.drawAnnotations(pdfSurface, pageAnnotations.Data, scale, pageHeight); err != nil { + return err + } + } + + // Add page numbers if requested + if p.options.AddPageNumbers { + p.drawPageNumber(pdfSurface, pageCount, pageWidth, pageHeight) + } + + // Show page (prepare for next page) + if pageCount < len(zip.Pages) || p.options.AllPages { + pdfSurface.ShowPage() + } + } + + pdfSurface.Finish() + + // Copy temp file to output + tmpFileRead, err := os.Open(tmpPath) + if err != nil { + return fmt.Errorf("failed to open temp file: %w", err) + } + defer tmpFileRead.Close() + + _, err = io.Copy(output, tmpFileRead) + return err +} + +func (p *PdfGenerator) generateWithBackground(zip *MyArchive, output io.Writer) error { + // Step 1: Create annotations-only PDF + tmpAnnotations, err := os.CreateTemp("", "rmfakecloud-annotations-*.pdf") + if err != nil { + return fmt.Errorf("failed to create temp annotations file: %w", err) + } + tmpAnnotationsPath := tmpAnnotations.Name() + tmpAnnotations.Close() + defer os.Remove(tmpAnnotationsPath) + + // Generate annotations PDF to temp file + annotationsFile, err := os.Create(tmpAnnotationsPath) + if err != nil { + return fmt.Errorf("failed to create annotations file: %w", err) + } + if err := p.generateAnnotationsOnly(zip, annotationsFile); err != nil { + annotationsFile.Close() + return err + } + annotationsFile.Close() + + // Step 2: Write background PDF to temp file + tmpBackground, err := os.CreateTemp("", "rmfakecloud-background-*.pdf") + if err != nil { + return fmt.Errorf("failed to create temp background file: %w", err) + } + tmpBackgroundPath := tmpBackground.Name() + if _, err := tmpBackground.Write(p.backgroundPDF); err != nil { + tmpBackground.Close() + os.Remove(tmpBackgroundPath) + return fmt.Errorf("failed to write background PDF: %w", err) + } + tmpBackground.Close() + defer os.Remove(tmpBackgroundPath) + + // Step 3: Merge background and annotations using pdfcpu + tmpOutput, err := os.CreateTemp("", "rmfakecloud-merged-*.pdf") + if err != nil { + return fmt.Errorf("failed to create temp output file: %w", err) + } + tmpOutputPath := tmpOutput.Name() + tmpOutput.Close() + defer os.Remove(tmpOutputPath) + + outFile, err := os.Create(tmpOutputPath) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer outFile.Close() + + // Open both PDFs as ReadSeekers + bgFile, err := os.Open(tmpBackgroundPath) + if err != nil { + return fmt.Errorf("failed to open background PDF: %w", err) + } + defer bgFile.Close() + + annFile, err := os.Open(tmpAnnotationsPath) + if err != nil { + return fmt.Errorf("failed to open annotations PDF: %w", err) + } + defer annFile.Close() + + // Merge: background first, then overlay annotations + conf := model.NewDefaultConfiguration() + rsc := []io.ReadSeeker{bgFile, annFile} + if err := api.MergeRaw(rsc, outFile, false, conf); err != nil { + return fmt.Errorf("failed to merge PDFs: %w", err) + } + outFile.Close() + + // Copy merged result to output + mergedFile, err := os.Open(tmpOutputPath) + if err != nil { + return fmt.Errorf("failed to open merged file: %w", err) + } + defer mergedFile.Close() + + _, err = io.Copy(output, mergedFile) + return err +} + +func (p *PdfGenerator) drawAnnotations(surface *cairo.Surface, rmData *rm.Rm, scale, pageHeight float64) error { + surface.Save() + defer surface.Restore() + + for _, layer := range rmData.Layers { + for _, line := range layer.Lines { + if len(line.Points) < 1 { + continue + } + if line.BrushType == rm.Eraser || line.BrushType == rm.EraseArea { + continue + } + + if line.BrushType == rm.HighlighterV5 { + // Draw highlighter as semi-transparent rectangle + p.drawHighlighter(surface, line, scale, pageHeight) + } else { + // Draw regular stroke + p.drawStroke(surface, line, scale, pageHeight) + } + } + } + + return nil +} + +func (p *PdfGenerator) drawHighlighter(surface *cairo.Surface, line rm.Line, scale, pageHeight float64) { + if len(line.Points) < 2 { + return + } + + last := len(line.Points) - 1 + x1, y1 := normalized(line.Points[0], scale) + x2, _ := normalized(line.Points[last], scale) + + // Highlighter width + width := scale * 30 + y1 += width / 2 + + // Convert Y coordinate (Cairo origin is top-left, PDF is bottom-left) + y := pageHeight - y1 + + // Yellow color with 50% opacity + surface.SetSourceRGBA(1.0, 1.0, 0.0, 0.5) + surface.SetLineWidth(width) + surface.SetLineCap(cairo.LINE_CAP_BUTT) + + surface.MoveTo(x1, y) + surface.LineTo(x2, y) + surface.Stroke() +} + +func (p *PdfGenerator) drawStroke(surface *cairo.Surface, line rm.Line, scale, pageHeight float64) { + if len(line.Points) < 1 { + return + } + + // Set stroke color + var r, g, b float64 + switch line.BrushColor { + case rm.Black: + r, g, b = 0.0, 0.0, 0.0 + case rm.White: + r, g, b = 1.0, 1.0, 1.0 + case rm.Grey: + r, g, b = 0.5, 0.5, 0.5 + default: + r, g, b = 0.0, 0.0, 0.0 + } + surface.SetSourceRGB(r, g, b) + + // Set stroke width + // Formula from original: line.BrushSize*6.0 - 10.8 + strokeWidth := float64(line.BrushSize)*6.0 - 10.8 + if strokeWidth < 0.5 { + strokeWidth = 0.5 + } + surface.SetLineWidth(strokeWidth) + + // Set line cap + surface.SetLineCap(cairo.LINE_CAP_ROUND) + surface.SetLineJoin(cairo.LINE_JOIN_ROUND) + + // Draw path + for i, point := range line.Points { + x, y := normalized(point, scale) + // Convert Y coordinate + y = pageHeight - y + + if i == 0 { + surface.MoveTo(x, y) + } else { + surface.LineTo(x, y) + } + } + + surface.Stroke() +} + +func (p *PdfGenerator) drawPageNumber(surface *cairo.Surface, pageNum int, pageWidth, pageHeight float64) { + surface.Save() + defer surface.Restore() + + surface.SelectFontFace("sans-serif", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) + surface.SetFontSize(8.0) + surface.SetSourceRGB(0, 0, 0) + + text := fmt.Sprintf("%d", pageNum) + surface.MoveTo(pageWidth-20, pageHeight-10) + surface.ShowText(text) +} + +func (p *PdfGenerator) initBackgroundPages(r io.ReadSeeker) error { + if r != nil { + // Read the PDF into memory + pdfBytes, err := io.ReadAll(r) + if err != nil { + return fmt.Errorf("failed to read background PDF: %w", err) + } + + // Check if PDF is encrypted and handle with pdfcpu + rs := bytes.NewReader(pdfBytes) + ctx, err := api.ReadContext(rs, model.NewDefaultConfiguration()) + if err != nil { + return fmt.Errorf("failed to read PDF: %w", err) + } + + // Check if encrypted by checking if Encrypt field exists + if ctx.XRefTable.Encrypt != nil { + logrus.Info("PDF is encrypted - pdfcpu will handle decryption") + // pdfcpu's ReadContext already handles decryption with empty password + } + + p.backgroundPDF = pdfBytes + p.template = false + return nil + } + + logrus.Info("template") + p.template = true + return nil +} diff --git a/internal/storage/exporter/pdf_stub.go b/internal/storage/exporter/pdf_stub.go new file mode 100644 index 00000000..0492470d --- /dev/null +++ b/internal/storage/exporter/pdf_stub.go @@ -0,0 +1,27 @@ +//go:build !cairo + +package exporter + +import ( + "errors" + "io" +) + +const ( + DeviceWidth = 1404 + DeviceHeight = 1872 +) + +type PdfGenerator struct { + options PdfGeneratorOptions +} + +type PdfGeneratorOptions struct { + AddPageNumbers bool + AllPages bool + AnnotationsOnly bool +} + +func (p *PdfGenerator) Generate(zip *MyArchive, output io.Writer, options PdfGeneratorOptions) error { + return errors.New("PDF generation with annotations requires building with Cairo support. Build with: go build -tags cairo") +} diff --git a/internal/storage/exporter/render.go b/internal/storage/exporter/render.go index 7d2fe0dd..974343f0 100644 --- a/internal/storage/exporter/render.go +++ b/internal/storage/exporter/render.go @@ -38,8 +38,8 @@ func RenderPoundifdef(input, output string) (io.ReadCloser, error) { return writer, nil } -// RenderRmapi renders with rmapi -func RenderRmapi(a *MyArchive, output io.Writer) error { +// RenderPDF renders a reMarkable archive to PDF using the Cairo-based PDF generator +func RenderPDF(a *MyArchive, output io.Writer) error { pdfgen := PdfGenerator{} options := PdfGeneratorOptions{ AllPages: true, diff --git a/internal/storage/exporter/rmc_go.go b/internal/storage/exporter/rmc_go.go new file mode 100644 index 00000000..1c4c3ff0 --- /dev/null +++ b/internal/storage/exporter/rmc_go.go @@ -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 +} diff --git a/internal/storage/exporter/version.go b/internal/storage/exporter/version.go new file mode 100644 index 00000000..a50a0ec8 --- /dev/null +++ b/internal/storage/exporter/version.go @@ -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") +} diff --git a/internal/storage/exporter/version_test.go b/internal/storage/exporter/version_test.go new file mode 100644 index 00000000..07615db7 --- /dev/null +++ b/internal/storage/exporter/version_test.go @@ -0,0 +1,147 @@ +package exporter + +import ( + "bytes" + "strings" + "testing" +) + +func TestDetectRmVersion(t *testing.T) { + tests := []struct { + name string + header string + want RmVersion + wantErr bool + }{ + { + name: "v6 format", + header: "reMarkable .lines file, version=6 ", // 43 bytes + want: VersionV6, + wantErr: false, + }, + { + name: "v5 format", + header: "reMarkable .lines file, version=5\n", + want: VersionV5, + wantErr: false, + }, + { + name: "v3 format", + header: "reMarkable .lines file, version=3\n", + want: VersionV3, + wantErr: false, + }, + { + name: "unknown format", + header: "Some other format", + want: VersionUnknown, + wantErr: true, + }, + { + name: "empty input", + header: "", + want: VersionUnknown, + wantErr: true, + }, + { + name: "truncated v6 header", + header: "reMarkable .lines file, version=6", + want: VersionV6, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader := strings.NewReader(tt.header) + got, err := DetectRmVersion(reader) + + if (err != nil) != tt.wantErr { + t.Errorf("DetectRmVersion() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if got != tt.want { + t.Errorf("DetectRmVersion() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDetectRmVersionFromBytes(t *testing.T) { + tests := []struct { + name string + data []byte + want RmVersion + wantErr bool + }{ + { + name: "v6 bytes", + data: []byte("reMarkable .lines file, version=6 "), + want: VersionV6, + wantErr: false, + }, + { + name: "v5 bytes", + data: []byte("reMarkable .lines file, version=5\nsomedata"), + want: VersionV5, + wantErr: false, + }, + { + name: "v3 bytes", + data: []byte("reMarkable .lines file, version=3\nsomedata"), + want: VersionV3, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DetectRmVersionFromBytes(tt.data) + + if (err != nil) != tt.wantErr { + t.Errorf("DetectRmVersionFromBytes() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if got != tt.want { + t.Errorf("DetectRmVersionFromBytes() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRmVersionString(t *testing.T) { + tests := []struct { + version RmVersion + want string + }{ + {VersionV3, "v3"}, + {VersionV5, "v5"}, + {VersionV6, "v6"}, + {VersionUnknown, "unknown"}, + {RmVersion(99), "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := tt.version.String(); got != tt.want { + t.Errorf("RmVersion.String() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDetectRmVersionPriorityV6(t *testing.T) { + // Ensure v6 is detected correctly even with similar v5/v3 strings + reader := bytes.NewReader([]byte("reMarkable .lines file, version=6 ")) + got, err := DetectRmVersion(reader) + + if err != nil { + t.Errorf("DetectRmVersion() unexpected error = %v", err) + } + + if got != VersionV6 { + t.Errorf("DetectRmVersion() = %v, want %v", got, VersionV6) + } +} \ No newline at end of file diff --git a/internal/storage/fs/blobstore.go b/internal/storage/fs/blobstore.go index cffa85f0..9de373b8 100644 --- a/internal/storage/fs/blobstore.go +++ b/internal/storage/fs/blobstore.go @@ -5,21 +5,24 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "io" "net/url" "os" "path" + "path/filepath" "strconv" "strings" "time" - "github.com/danjacques/gofslock/fslock" + "github.com/ddvk/rmfakecloud/internal/archive" "github.com/ddvk/rmfakecloud/internal/common" "github.com/ddvk/rmfakecloud/internal/config" "github.com/ddvk/rmfakecloud/internal/storage" "github.com/ddvk/rmfakecloud/internal/storage/exporter" "github.com/ddvk/rmfakecloud/internal/storage/models" "github.com/google/uuid" + "github.com/juju/fslock" log "github.com/sirupsen/logrus" ) @@ -128,20 +131,164 @@ func (fs *FileSystemStorage) Export(uid, docid string) (r io.ReadCloser, err err } ls := fs.BlobStorage(uid) - archive, err := models.ArchiveFromHashDoc(doc, ls) - if err != nil { - return nil, err + // Detect version BEFORE trying to load archive + // This is crucial because v6 files can't be unmarshaled by rmapi + version := exporter.VersionUnknown + var firstRmHash string + + // Find first .rm file in doc + for _, f := range doc.Files { + if filepath.Ext(f.EntryName) == storage.RmFileExt { + firstRmHash = f.Hash + break + } } + + // Detect version from raw .rm blob + if firstRmHash != "" { + reader, err := ls.GetReader(firstRmHash) + if err == nil { + defer reader.Close() + // Read just enough for version detection + header := make([]byte, 43) + n, err := reader.Read(header) + if err == nil || err == io.EOF { + version, _ = exporter.DetectRmVersionFromBytes(header[:n]) + } + } + } + + log.Debugf("Detected format %s for blob doc %s", version.String(), docid) + reader, writer := io.Pipe() - go func() { - err = exporter.RenderRmapi(archive, writer) + + // Route to appropriate renderer + if version == exporter.VersionV6 { + log.Infof("Using native rmc-go for v6 format blob doc %s", docid) + + go func() { + defer writer.Close() + + // Extract .rm files directly from blob storage without parsing + // First, get content.json to know page order + var contentData archive.Content + for _, f := range doc.Files { + if filepath.Ext(f.EntryName) == storage.ContentFileExt { + blob, err := ls.GetReader(f.Hash) + if err == nil { + contentBytes, _ := io.ReadAll(blob) + blob.Close() + err = json.Unmarshal(contentBytes, &contentData) + if err != nil { + log.Warnf("Failed to unmarshal content.json: %v", err) + } + } + break + } + } + + log.Debugf("Content has %d pages", len(contentData.Pages)) + + // Build map of page names to hashes + pageMap := make(map[string]string) + for _, f := range doc.Files { + if filepath.Ext(f.EntryName) == storage.RmFileExt { + name := strings.TrimSuffix(filepath.Base(f.EntryName), storage.RmFileExt) + pageMap[name] = f.Hash + log.Debugf("Found .rm file: %s -> %s", name, f.Hash) + } + } + + log.Debugf("Built page map with %d entries", len(pageMap)) + + // Extract all pages in order + var pageHashes []string + if len(contentData.Pages) > 0 { + // Use pages from content.json in the correct order + for _, pageName := range contentData.Pages { + if hash, ok := pageMap[pageName]; ok { + pageHashes = append(pageHashes, hash) + log.Debugf("Found page %s -> %s", pageName, hash) + } else { + log.Warnf("Page %s not found in pageMap", pageName) + } + } + } else { + // No pages in content.json, use order from doc.Files (index file order) + // doc.Files is sorted alphabetically which reverses page order, so we reverse it back + log.Warn("content.json has no pages array, using .rm files in reversed index order") + var tempHashes []string + for _, f := range doc.Files { + if filepath.Ext(f.EntryName) == storage.RmFileExt { + tempHashes = append(tempHashes, f.Hash) + } + } + // Reverse the order to get correct page sequence + for i := len(tempHashes) - 1; i >= 0; i-- { + pageHashes = append(pageHashes, tempHashes[i]) + log.Infof("Using .rm file in reversed order: page %d", len(tempHashes)-i) + } + } + + if len(pageHashes) == 0 { + log.Error("No pages found in v6 document") + log.Debugf("Doc files: %+v", doc.Files) + writer.CloseWithError(fmt.Errorf("no pages found")) + return + } + + log.Infof("Exporting %d v6 pages", len(pageHashes)) + + // Read all pages into memory + var pages [][]byte + for i, pageHash := range pageHashes { + rmReader, err := ls.GetReader(pageHash) + if err != nil { + log.Errorf("Failed to get v6 page %d data: %v", i, err) + writer.CloseWithError(err) + return + } + + rmData, err := io.ReadAll(rmReader) + rmReader.Close() + if err != nil { + log.Errorf("Failed to read v6 page %d data: %v", i, err) + writer.CloseWithError(err) + return + } + + pages = append(pages, rmData) + } + + // Use rmc-go library for multipage export (in-process, Cairo renderer) + err = exporter.ExportV6MultiPageToPdfNative(pages, writer) + if err != nil { + log.Errorf("Failed to export v6 multipage with rmc-go: %v", err) + writer.CloseWithError(err) + return + } + }() + } else { + // Use existing v5 rendering + log.Debugf("Using Cairo PDF renderer for v5 format blob doc %s", docid) + + archive, err := models.ArchiveFromHashDoc(doc, ls) if err != nil { - log.Error(err) - writer.Close() - return + log.Error("Failed to load v5 archive:", err) + return nil, err } - writer.Close() - }() + + go func() { + err = exporter.RenderPDF(archive, writer) + if err != nil { + log.Error(err) + writer.Close() + return + } + writer.Close() + }() + } + return reader, err } @@ -646,7 +793,8 @@ func (fs *FileSystemStorage) LoadBlob(uid, blobid string) (reader io.ReadCloser, log.Debugln("Fullpath:", blobPath) if blobid == rootBlob { historyPath := path.Join(fs.getUserBlobPath(uid), historyFile) - lock, err := fslock.Lock(historyPath) + lock := fslock.New(historyPath) + err := lock.LockWithTimeout(time.Duration(time.Second * 5)) if err != nil { log.Error("cannot obtain lock") return nil, 0, 0, "", err @@ -691,11 +839,10 @@ func (fs *FileSystemStorage) StoreBlob(uid, id string, stream io.Reader, lastGen reader := stream if id == rootBlob { historyPath := path.Join(fs.getUserBlobPath(uid), historyFile) - var lock fslock.Handle - lock, err = fslock.Lock(historyPath) + lock := fslock.New(historyPath) + err = lock.LockWithTimeout(time.Duration(time.Second * 5)) if err != nil { log.Error("cannot obtain lock") - return 0, err } defer lock.Unlock() @@ -760,3 +907,4 @@ func generationFromFileSize(size int64) int64 { //time + 1 space + 64 hash + 1 newline return size / 86 } + diff --git a/internal/storage/fs/documents.go b/internal/storage/fs/documents.go index f6a4335a..ae5cd678 100644 --- a/internal/storage/fs/documents.go +++ b/internal/storage/fs/documents.go @@ -6,8 +6,8 @@ import ( "io" "net/url" "os" - "path" "path/filepath" + "sort" "time" "github.com/golang-jwt/jwt/v4" @@ -69,7 +69,7 @@ func (fs *FileSystemStorage) ExportDocument(uid, id, outputType string, exportOp return nil, fmt.Errorf("cant find raw document %v", err) } - outputFilePath := path.Join(cacheDirPath, sanitizedID+"-annotated.pdf") + outputFilePath := filepath.Join(cacheDirPath, sanitizedID+"-annotated.pdf") outStat, err := os.Stat(outputFilePath) // exists and not older @@ -93,15 +93,64 @@ func (fs *FileSystemStorage) ExportDocument(uid, id, outputType string, exportOp arch.PayloadReader = exporter.NewSeekCloser(arch.Payload) } - outputFile, err := os.Create(outputFilePath) - if err != nil { - return nil, err + // Detect version from first .rm file in archive + version := exporter.VersionUnknown + if len(arch.Pages) > 0 { + version, err = exporter.DetectArchiveVersion(arch) + if err != nil { + log.Warnf("Could not detect version for doc %s: %v, assuming v5", sanitizedID, err) + version = exporter.VersionV5 + } } - err = exporter.RenderRmapi(arch, outputFile) + log.Debugf("Detected format %s for doc %s", version.String(), sanitizedID) + + outputFile, err := os.Create(outputFilePath) if err != nil { return nil, err } + defer outputFile.Close() + + // Route to appropriate renderer based on version + if version == exporter.VersionV6 { + log.Infof("Using native rmc-go for v6 format doc %s", sanitizedID) + + // Use native rmc-go library (Cairo renderer) + if len(arch.V6PageData) > 0 { + // Collect all v6 pages in the correct order + // Get sorted page indices to ensure correct order + pageIndices := make([]int, 0, len(arch.V6PageData)) + for idx := range arch.V6PageData { + pageIndices = append(pageIndices, idx) + } + sort.Ints(pageIndices) + + // Collect pages in sorted order + var pages [][]byte + for _, idx := range pageIndices { + pages = append(pages, arch.V6PageData[idx]) + } + + if len(pages) == 0 { + return nil, fmt.Errorf("no v6 page data found in archive") + } + + // Use multipage export function + err = exporter.ExportV6MultiPageToPdfNative(pages, outputFile) + if err != nil { + return nil, fmt.Errorf("v6 native multipage export failed: %w", err) + } + } else { + return nil, fmt.Errorf("no v6 pages in archive") + } + } else { + // Use Cairo PDF renderer for v5 + log.Debugf("Using Cairo PDF renderer for v5 format doc %s", sanitizedID) + err = exporter.RenderPDF(arch, outputFile) + if err != nil { + return nil, fmt.Errorf("v5 export failed: %w", err) + } + } _, err = outputFile.Seek(0, 0) if err != nil { @@ -122,6 +171,7 @@ func (fs *FileSystemStorage) GetDocument(uid, id string) (io.ReadCloser, error) // RemoveDocument removes document (moves it to trash) func (fs *FileSystemStorage) RemoveDocument(uid, id string) error { + sanitizedID := common.Sanitize(id) trashDir := fs.getPathFromUser(uid, DefaultTrashDir) err := os.MkdirAll(trashDir, 0700) @@ -130,16 +180,16 @@ func (fs *FileSystemStorage) RemoveDocument(uid, id string) error { } //do not delete, move to trash log.Info(trashDir) - meta := filepath.Base(id + storage.MetadataFileExt) + meta := sanitizedID + storage.MetadataFileExt fullPath := fs.getPathFromUser(uid, meta) - err = os.Rename(fullPath, path.Join(trashDir, meta)) + err = os.Rename(fullPath, filepath.Join(trashDir, meta)) if err != nil { return err } - zipfile := filepath.Base(id + storage.ZipFileExt) + zipfile := sanitizedID + storage.ZipFileExt fullPath = fs.getPathFromUser(uid, zipfile) - err = os.Rename(fullPath, path.Join(trashDir, zipfile)) + err = os.Rename(fullPath, filepath.Join(trashDir, zipfile)) if err != nil { return err } @@ -179,3 +229,4 @@ func (fs *FileSystemStorage) GetStorageURL(uid, id string) (docurl string, expir return fmt.Sprintf("%s%s/%s", uploadRL, routeStorage, url.QueryEscape(signedToken)), exp, nil } + diff --git a/internal/storage/models/archive.go b/internal/storage/models/archive.go index d244db6d..5830a9a6 100644 --- a/internal/storage/models/archive.go +++ b/internal/storage/models/archive.go @@ -6,10 +6,10 @@ import ( "path" "strings" + "github.com/ddvk/rmfakecloud/internal/archive" + "github.com/ddvk/rmfakecloud/internal/encoding/rm" "github.com/ddvk/rmfakecloud/internal/storage" "github.com/ddvk/rmfakecloud/internal/storage/exporter" - "github.com/juruen/rmapi/archive" - "github.com/juruen/rmapi/encoding/rm" log "github.com/sirupsen/logrus" ) @@ -20,6 +20,7 @@ func ArchiveFromHashDoc(doc *HashDoc, rs RemoteStorage) (*exporter.MyArchive, er Zip: archive.Zip{ UUID: uuid, }, + V6PageData: make(map[int][]byte), // Initialize map for v6 raw data } pageMap := make(map[string]string) @@ -65,7 +66,7 @@ func ArchiveFromHashDoc(doc *HashDoc, rs RemoteStorage) (*exporter.MyArchive, er } } - for _, p := range a.Content.Pages { + for pageIdx, p := range a.Content.Pages { if hash, ok := pageMap[p]; ok { log.Debug("page ", hash) reader, err := rs.GetReader(hash) @@ -76,17 +77,55 @@ func ArchiveFromHashDoc(doc *HashDoc, rs RemoteStorage) (*exporter.MyArchive, er if err != nil { return nil, err } - rmpage := rm.New() - err = rmpage.UnmarshalBinary(pageBin) - if err != nil { - return nil, err - } - page := archive.Page{ - Data: rmpage, - Pagedata: "Blank", + // Try to detect version first + version, versionErr := exporter.DetectRmVersionFromBytes(pageBin) + + // For v5 and earlier, parse with rmapi + if versionErr == nil && (version == exporter.VersionV3 || version == exporter.VersionV5) { + rmpage := rm.New() + err = rmpage.UnmarshalBinary(pageBin) + if err != nil { + log.Warnf("Failed to unmarshal v5 page: %v", err) + return nil, err + } + + page := archive.Page{ + Data: rmpage, + Pagedata: "Blank", + } + a.Pages = append(a.Pages, page) + } else if versionErr == nil && version == exporter.VersionV6 { + // For v6, we can't unmarshal with rmapi + // Store the raw bytes in V6PageData map + log.Debugf("Detected v6 page, storing raw data for page %d", pageIdx) + + // Store raw v6 data in the map + a.V6PageData[pageIdx] = pageBin + + // Create a dummy page for structure compatibility + rmpage := rm.New() + page := archive.Page{ + Data: rmpage, // Empty, but needed for structure + Pagedata: "Blank", + } + a.Pages = append(a.Pages, page) + } else { + log.Warnf("Unknown rm file version or detection failed: %v", versionErr) + // Try to parse as v5 anyway (backward compatibility) + rmpage := rm.New() + err = rmpage.UnmarshalBinary(pageBin) + if err != nil { + log.Warnf("Failed to unmarshal page: %v", err) + return nil, err + } + + page := archive.Page{ + Data: rmpage, + Pagedata: "Blank", + } + a.Pages = append(a.Pages, page) } - a.Pages = append(a.Pages, page) } }