From 94811ca7c160223a8bd9a0820a35b38010696537 Mon Sep 17 00:00:00 2001 From: Fenix Date: Wed, 28 Jan 2026 20:39:59 -0800 Subject: [PATCH] Initial PoC commit --- .DS_Store | Bin 0 -> 6148 bytes Server/.env | 135 +++++++++++++++++ Server/config.py | 36 +++++ Server/database.py | 129 ++++++++++++++++ Server/htr-api.service | 29 ++++ Server/main.py | 139 ++++++++++++++++++ Server/vllm-htr.service | 36 +++++ about.html | 88 +++++++++++ credits.html | 102 +++++++++++++ feedback.html | 73 ++++++++++ help.html | 149 +++++++++++++++++++ index.html | 67 +++++++++ journal.css | 266 +++++++++++++++++++++++++++++++++ journal.js | 315 ++++++++++++++++++++++++++++++++++++++++ legal.html | 137 +++++++++++++++++ menu.html | 34 +++++ screen.jpg | Bin 0 -> 8186 bytes start.html | 96 ++++++++++++ 18 files changed, 1831 insertions(+) create mode 100644 .DS_Store create mode 100644 Server/.env create mode 100644 Server/config.py create mode 100644 Server/database.py create mode 100644 Server/htr-api.service create mode 100644 Server/main.py create mode 100644 Server/vllm-htr.service create mode 100644 about.html create mode 100644 credits.html create mode 100644 feedback.html create mode 100644 help.html create mode 100644 index.html create mode 100644 journal.css create mode 100644 journal.js create mode 100644 legal.html create mode 100644 menu.html create mode 100644 screen.jpg create mode 100644 start.html diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..f9224841aded0fb4de01804a85a0e798237b097e GIT binary patch literal 6148 zcmeHKF>V4u473x3kZ34V?iccd6(TR-0|-cmgp>&Ct9VzQmKoax=t4(|L}ST2yI#+( zc8c@a%zSy;znU$~Yz8OVg~QmmPaoM+MH~pn8SnP7|J)t6$Kzp?{d0iasjbPh^EICv zGASShq<|EV0#e}N3RH=m%^!ZU4wM2?;IAvd??ZzVd*P56pAHPs0stonhhZMQ1h6px z?1e)jA}~)XFsWWGh9@2IR(ZW}NKCqUH6LC#dvz!tx8wX4>E=CAqZE(=w+cMxa>4rl z2EWt)-;%VF0#e{#Dd3aUdbPxps9&EXOaAlzEMF X?DxVUG3bZ~9jKoH>LQZ@zpcPGY26iv literal 0 HcmV?d00001 diff --git a/Server/.env b/Server/.env new file mode 100644 index 0000000..6aef77e --- /dev/null +++ b/Server/.env @@ -0,0 +1,135 @@ +# =========================================== +# HTR API Configuration - Enhanced Version +# =========================================== + +# --- Model Selection --- +# Change these when switching models: +VLLM_MODEL_PATH=/llm/models/Qwen3-VL-8B-AWQ4 +VLLM_MODEL_NAME=Qwen3-VL-8B-AWQ4 + +# --- vLLM Server Settings --- +VLLM_HOST=0.0.0.0 +VLLM_PORT=8001 +VLLM_GPU_UTIL=0.90 + +# Model-specific max context length +VLLM_MAX_MODEL_LEN=2560 + +# --- PERFORMANCE BOOST: KV Cache Quantization --- +# This is the #1 performance optimization! +# Options: auto (default), fp8, fp8_e4m3, fp8_e5m2, int8 +# fp8 = 2x more concurrent users, minimal quality loss (<0.1%) +VLLM_KV_CACHE_DTYPE=fp8 + +# --- Sampling Parameters (Override model defaults!) --- +# CRITICAL: These override model config.json defaults +# Without these, vLLM uses model-specific defaults which may vary + +# Temperature: 0.0 = deterministic, higher = more creative +# For HTR: 0.1 is perfect (consistent but not stuck in loops) +SAMPLING_TEMPERATURE=0.1 + +# Max tokens to generate per request +SAMPLING_MAX_TOKENS=500 + +# Top-p sampling (0.0-1.0) - use top 95% of probability mass +SAMPLING_TOP_P=0.95 + +# Top-k sampling (0 = disabled) +SAMPLING_TOP_K=0 + +# Presence penalty (0.0 = none, positive = encourage diversity) +SAMPLING_PRESENCE_PENALTY=0.0 + +# Frequency penalty (0.0 = none, positive = reduce repetition) +SAMPLING_FREQUENCY_PENALTY=0.0 + +# --- CRITICAL: Stop Sequences --- +# This is WHY your 2B strips newlines in vLLM but not LM Studio! +# Model config.json may have default stop tokens like ["\n", "\n\n"] +# Empty = Override model defaults, preserve ALL newlines +SAMPLING_STOP_SEQUENCES= + +# Alternative: Use only model's special tokens (if needed) +# SAMPLING_STOP_SEQUENCES=<|endoftext|>,<|im_end|> + +# --- FastAPI Settings --- +VLLM_ENDPOINT=http://127.0.0.1:8001/v1/chat/completions + +# --- Database --- +DATABASE_PATH=/home/fenix/htr-api/htr_usage.db + +# --- Limits --- +MAX_IMAGE_SIZE=10485760 + +# --- OpenRouter (for future cloud fallback) --- +# OPENROUTER_API_KEY=your_key_here +# OPENROUTER_ENDPOINT=https://openrouter.ai/api/v1/chat/completions +# OPENROUTER_MODEL=qwen/qwen3-vl-8b-instruct + +# =========================================== +# Model-Specific Presets (Copy to active settings above) +# =========================================== + +# --- Qwen3-VL-2B-FP8 (Fastest: 161 tok/s) --- +# VLLM_MODEL_PATH=/llm/models/Qwen3-VL-2B-FP8 +# VLLM_MODEL_NAME=Qwen3-VL-2B-FP8 +# VLLM_MAX_MODEL_LEN=8192 +# VLLM_KV_CACHE_DTYPE=fp8 +# Model size: 3.5GB | Concurrent: ~35-40 users with fp8 cache + +# --- Qwen3-VL-2B-BF16 (Fast: 121.8 tok/s) --- +# VLLM_MODEL_PATH=/llm/models/Qwen3-VL-2B-BF16 +# VLLM_MODEL_NAME=Qwen3-VL-2B-BF16 +# VLLM_MAX_MODEL_LEN=8192 +# VLLM_KV_CACHE_DTYPE=fp8 +# Model size: 4.3GB | Concurrent: ~35-40 users with fp8 cache + +# --- Qwen3-VL-8B-AWQ4 (Balanced: 88 tok/s, 99.17% accuracy) --- +# VLLM_MODEL_PATH=/llm/models/qwen3-vl-8b-awq4 +# VLLM_MODEL_NAME=qwen3-vl-8b-awq4 +# VLLM_MAX_MODEL_LEN=8192 +# VLLM_KV_CACHE_DTYPE=fp8 +# Model size: 7.5GB | Concurrent: ~25-30 users with fp8 cache + +# --- Qwen3-VL-8B-AWQ8 (Good: 61 tok/s) --- +# VLLM_MODEL_PATH=/llm/models/Qwen3-VL-8B-AWQ-8bit +# VLLM_MODEL_NAME=Qwen3-VL-8B-AWQ-8bit +# VLLM_MAX_MODEL_LEN=8192 +# VLLM_KV_CACHE_DTYPE=fp8 +# Model size: 10.8GB | Concurrent: ~20-25 users with fp8 cache + +# --- Qwen3-VL-8B-BF16 (Highest quality: 42.1 tok/s) --- +# VLLM_MODEL_PATH=/llm/models/qwen3-vl-8b-bf16 +# VLLM_MODEL_NAME=qwen3-vl-8b-bf16 +# VLLM_MAX_MODEL_LEN=8192 +# VLLM_KV_CACHE_DTYPE=fp8 +# Model size: 17.5GB | Concurrent: ~15-20 users with fp8 cache + +# =========================================== +# Performance Impact Summary +# =========================================== + +# KV_CACHE_DTYPE Impact on Concurrent Capacity (A6000 48GB): +# ┌─────────────┬──────────────┬─────────────┬────────────┐ +# │ Model │ auto (16bit) │ fp8 (8bit) │ Speedup │ +# ├─────────────┼──────────────┼─────────────┼────────────┤ +# │ 2B-FP8 │ ~18-20 users │ ~35-40 users│ 2.0x │ +# │ 2B-BF16 │ ~16-18 users │ ~35-40 users│ 2.2x │ +# │ 8B-AWQ4 │ ~14-16 users │ ~25-30 users│ 1.9x │ +# │ 8B-AWQ8 │ ~12-14 users │ ~20-25 users│ 1.8x │ +# │ 8B-BF16 │ ~8-10 users │ ~15-20 users│ 2.0x │ +# └─────────────┴──────────────┴─────────────┴────────────┘ + +# Accuracy impact: <0.1% drop with fp8 cache (negligible) +# Recommendation: ALWAYS use fp8 for production + +# Temperature Impact: +# 0.0 = Fully deterministic (can cause loops) +# 0.1 = Near-deterministic, robust (RECOMMENDED for HTR) +# 0.3 = Slight variation +# 0.7+ = Too creative for transcription + +# Stop Sequences Impact: +# WITH newline stops: 97.10% char accuracy (paragraphs merged) +# WITHOUT newline stops: 99.17% char accuracy (preserves formatting) diff --git a/Server/config.py b/Server/config.py new file mode 100644 index 0000000..dd4a18e --- /dev/null +++ b/Server/config.py @@ -0,0 +1,36 @@ +""" +Configuration settings for HTR API on Phoenix +""" + +from pydantic_settings import BaseSettings +from typing import Optional + + +class Settings(BaseSettings): + jwt_secret: str = "JWT_SECRET=73936f5c69eb84f013a531a35ffae040855cd6c7891ed1bb0872780fe8c56274" + jwt_algorithm: str = "HS256" + + # vLLM Configuration - matches your local setup + vllm_endpoint: str = "http://127.0.0.1:8001/v1/chat/completions" + vllm_model: str = "qwen3-vl" # served-model-name from vLLM + + # LLM Configuration (OpenRouter or local vLLM) + llm_endpoint: str = "http://127.0.0.1:8001/v1/chat/completions" + llm_model: str = "qwen3-vl" + openrouter_api_key: Optional[str] = None + + # Database + database_path: str = "/home/fenix/htr-api/htr_usage.db" + + # Limits + max_image_size: int = 10 * 1024 * 1024 + + # WordPress + upgrade_url: str = "https://prometheuscafe.com/plans" + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + +settings = Settings() diff --git a/Server/database.py b/Server/database.py new file mode 100644 index 0000000..ac8b417 --- /dev/null +++ b/Server/database.py @@ -0,0 +1,129 @@ +""" +SQLite database for tracking daily usage +""" + +import sqlite3 +from datetime import datetime, timezone, timedelta +from config import settings +import threading + +_local = threading.local() + + +def get_db(): + """Get thread-local database connection""" + if not hasattr(_local, "connection"): + _local.connection = sqlite3.connect( + settings.database_path, + check_same_thread=False + ) + _local.connection.row_factory = sqlite3.Row + return _local.connection + + +def init_db(): + """Initialize database schema""" + conn = get_db() + cursor = conn.cursor() + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + date TEXT NOT NULL, + count INTEGER DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, date) + ) + """) + + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_usage_user_date + ON usage(user_id, date) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS extraction_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + tier TEXT, + timestamp TEXT DEFAULT CURRENT_TIMESTAMP, + success INTEGER DEFAULT 1, + processing_time_ms INTEGER + ) + """) + + conn.commit() + cleanup_old_records() + + +def get_today_utc() -> str: + """Get today's date in UTC as string""" + return datetime.now(timezone.utc).strftime("%Y-%m-%d") + + +def get_usage_today(user_id: str) -> int: + """Get number of extractions used today for a user""" + conn = get_db() + cursor = conn.cursor() + + today = get_today_utc() + cursor.execute( + "SELECT count FROM usage WHERE user_id = ? AND date = ?", + (user_id, today) + ) + + row = cursor.fetchone() + return row["count"] if row else 0 + + +def increment_usage(user_id: str) -> int: + """Increment usage count for today. Returns new count.""" + conn = get_db() + cursor = conn.cursor() + + today = get_today_utc() + now = datetime.now(timezone.utc).isoformat() + + cursor.execute(""" + INSERT INTO usage (user_id, date, count, updated_at) + VALUES (?, ?, 1, ?) + ON CONFLICT(user_id, date) + DO UPDATE SET + count = count + 1, + updated_at = ? + """, (user_id, today, now, now)) + + conn.commit() + return get_usage_today(user_id) + + +def cleanup_old_records(days_to_keep: int = 30): + """Remove usage records older than specified days""" + conn = get_db() + cursor = conn.cursor() + + cutoff = (datetime.now(timezone.utc) - timedelta(days=days_to_keep)).strftime("%Y-%m-%d") + + cursor.execute("DELETE FROM usage WHERE date < ?", (cutoff,)) + cursor.execute("DELETE FROM extraction_log WHERE date(timestamp) < ?", (cutoff,)) + + conn.commit() + + +def get_usage_stats(user_id: str, days: int = 7) -> list: + """Get usage history for a user""" + conn = get_db() + cursor = conn.cursor() + + cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d") + + cursor.execute(""" + SELECT date, count + FROM usage + WHERE user_id = ? AND date >= ? + ORDER BY date DESC + """, (user_id, cutoff)) + + return [dict(row) for row in cursor.fetchall()] diff --git a/Server/htr-api.service b/Server/htr-api.service new file mode 100644 index 0000000..750cfae --- /dev/null +++ b/Server/htr-api.service @@ -0,0 +1,29 @@ +# =========================================== +# HTR FastAPI Service +# Reads configuration from /home/fenix/htr-api/.env +# =========================================== +# BACKUP COPY - After editing, run: +# sudo cp ~/htr-api/htr-api.service /etc/systemd/system/ +# sudo systemctl daemon-reload && sudo systemctl restart htr-api +# =========================================== + +[Unit] +Description=HTR FastAPI Service +After=network.target vllm-htr.service +Wants=vllm-htr.service + +[Service] +Type=simple +User=fenix +WorkingDirectory=/home/fenix/htr-api + +# Load environment variables from .env +EnvironmentFile=/home/fenix/htr-api/.env + +ExecStart=/home/fenix/htr-api/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000 + +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/Server/main.py b/Server/main.py new file mode 100644 index 0000000..ab4c7cf --- /dev/null +++ b/Server/main.py @@ -0,0 +1,139 @@ +""" +HTR API - Simplified Local vLLM Version +Reads configuration from environment variables (.env file) +""" + +import os +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +import httpx +import base64 + +# =========================================== +# Configuration from Environment +# =========================================== +VLLM_ENDPOINT = os.getenv("VLLM_ENDPOINT", "http://127.0.0.1:8001/v1/chat/completions") +VLLM_MODEL_NAME = os.getenv("VLLM_MODEL_NAME") +MAX_IMAGE_SIZE = int(os.getenv("MAX_IMAGE_SIZE", 10 * 1024 * 1024)) + +# Fail fast if critical env vars aren't set +if not VLLM_MODEL_NAME: + raise RuntimeError("VLLM_MODEL_NAME not set! Check .env file and htr-api.service EnvironmentFile path.") + +# =========================================== +# HTR Prompt +# =========================================== +HTR_PROMPT = """You are a handwriting transcription assistant. + +Your task is to transcribe the handwritten pages into plain text paragraphs, preserving the original wording exactly. + +Rules: +- Do not change, correct, or improve any spelling, grammar, punctuation, or sentence structure. +- Preserve paragraph breaks as they appear in the handwriting. Start a new paragraph only where the handwriting clearly starts a new paragraph that has a blank line. Do NOT insert line breaks on every sentence because the sentence is not fitting in the journal image. Just the paragraph break with the clear blank line between paragraphs. +- Also, insert 2 paragraphs breaks to clearly indicate a new paragraph. +- If you are unsure of a word or cannot read it, write ??? in its place. +- Do not add, remove, or rearrange any sentences. + +Only output the transcribed text following these rules.""" + +# =========================================== +# FastAPI App +# =========================================== +app = FastAPI( + title="HTR API", + description="Handwriting Text Recognition API for Prometheus Cafe", + version="2.0.0" +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# =========================================== +# Request/Response Models +# =========================================== +class ExtractRequest(BaseModel): + image: str # Base64 encoded image + +class ExtractResponse(BaseModel): + text: str + +# =========================================== +# Endpoints +# =========================================== +@app.get("/api/health") +async def health_check(): + """Health check endpoint""" + return { + "status": "healthy", + "backend": "local-vllm", + "model": VLLM_MODEL_NAME, + "endpoint": VLLM_ENDPOINT + } + +@app.post("/api/extract", response_model=ExtractResponse) +async def extract_text(request: ExtractRequest): + """Extract text from handwritten image using local vLLM.""" + + # Validate and clean image data + try: + image_data = request.image + if "," in image_data: + image_data = image_data.split(",")[1] + + decoded = base64.b64decode(image_data) + if len(decoded) > MAX_IMAGE_SIZE: + raise HTTPException( + status_code=400, + detail=f"Image too large. Max size: {MAX_IMAGE_SIZE // 1024 // 1024}MB" + ) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid image data: {str(e)}") + + # Call local vLLM + try: + async with httpx.AsyncClient(timeout=180.0) as client: + response = await client.post( + VLLM_ENDPOINT, + headers={"Content-Type": "application/json"}, + json={ + "model": VLLM_MODEL_NAME, + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": HTR_PROMPT}, + { + "type": "image_url", + "image_url": { + "url": f"data:image/jpeg;base64,{image_data}" + } + } + ] + } + ], + "max_tokens": 2048, + "temperature": 0.1, + } + ) + + if response.status_code != 200: + raise HTTPException( + status_code=500, + detail=f"vLLM error {response.status_code}: {response.text}" + ) + + result = response.json() + extracted_text = result["choices"][0]["message"]["content"] + + return ExtractResponse(text=extracted_text) + + except httpx.TimeoutException: + raise HTTPException(status_code=504, detail="vLLM request timed out") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Extraction failed: {str(e)}") diff --git a/Server/vllm-htr.service b/Server/vllm-htr.service new file mode 100644 index 0000000..09dac96 --- /dev/null +++ b/Server/vllm-htr.service @@ -0,0 +1,36 @@ +# =========================================== +# vLLM HTR Service +# Reads configuration from /home/fenix/htr-api/.env +# =========================================== +# BACKUP COPY - After editing, run: +# sudo cp ~/htr-api/vllm-htr.service /etc/systemd/system/ +# sudo systemctl daemon-reload && sudo systemctl restart vllm-htr +# =========================================== + +[Unit] +Description=vLLM Server for HTR +After=network.target + +[Service] +Type=simple +User=fenix +WorkingDirectory=/llm + +# Load environment variables from .env +EnvironmentFile=/home/fenix/htr-api/.env + +# Use environment variables in ExecStart +ExecStart=/llm/env/bin/vllm serve ${VLLM_MODEL_PATH} \ + --host ${VLLM_HOST} \ + --port ${VLLM_PORT} \ + --max-model-len ${VLLM_MAX_MODEL_LEN} \ + --gpu-memory-utilization ${VLLM_GPU_UTIL} \ + --trust-remote-code \ + --served-model-name ${VLLM_MODEL_NAME} + +Restart=on-failure +RestartSec=10 +TimeoutStartSec=300 + +[Install] +WantedBy=multi-user.target diff --git a/about.html b/about.html new file mode 100644 index 0000000..f3a10ef --- /dev/null +++ b/about.html @@ -0,0 +1,88 @@ + + + + + About This App – Pen2Post + + + + +
+
+ +
+ +
+

About This App

+

+ Pen2Post turns your handwritten journal pages into editable text you can refine, post, or publish. +

+ +

Why Pen2Post exists

+

+ Pen2Post is for people who think best with pen and paper, but still need their words in digital form. + It lets you stay in the quiet, distraction‑free space of a notebook and only go digital when you are ready. +

+

+ I built this tool first for myself. I love writing and being creative, but chronic illness and limited energy + mean I need tools that help me capture my own voice instead of asking AI to write for me. + Pen2Post lets me fill a journal in peace, then bring those words into my computer later – still sounding like me. +

+ +

The problem it solves

+

+ Typing directly into a computer can pull you out of creative flow: spell‑check and grammar tools nag you, + notifications pop up, and the web is always one click away from a rabbit hole. + A notebook has none of that – no alerts, no feeds, no tabs – just you and the page. +

+

+ The hard part is getting those handwritten pages into digital form without retyping everything by hand. + That is the friction Pen2Post removes. +

+ +

What Pen2Post does

+

+ Pen2Post works like a digital court reporter for your notebook: it takes a photo of your page, + reads your handwriting, and turns it into text you can edit and share. +

+
    +
  • Write freely in your journal like you always do.
  • +
  • Open Pen2Post and take a photo of a page or upload an existing photo.
  • +
  • Tap Extract Text and wait a short moment while the app reads your handwriting.
  • +
  • Fix any misread words, then share the text into your favorite apps or storage.
  • +
+

+ The app is intentionally simple: one main screen, a big text area, and a clear path from paper to post. + It is designed for real handwriting on real pages, not lab‑perfect samples. +

+ +

What it does not do

+

+ Pen2Post is not a writing coach or an auto‑rewrite engine. + It does not try to correct your spelling, fix your grammar, or change your style. +

+

+ The goal is to preserve your original voice. If the app cannot clearly read a word, + it may misinterpret it and you simply fix those spots as you shape the draft to match your journal. +

+ +

Part of Prometheus Cafe

+

+ Pen2Post is part of Prometheus Cafe, a project focused on helping aspiring entrepreneurs and creatives + get their ideas out of their heads and into the world. This app is one small tool in that larger mission: + reduce friction, respect your energy, and keep you connected to your own words. +

+ +

+ Ready to go from handwriting to posts? Snap a page, extract the text, and start shaping your next draft. +

+
+
+ + + + \ No newline at end of file diff --git a/credits.html b/credits.html new file mode 100644 index 0000000..f7d5d66 --- /dev/null +++ b/credits.html @@ -0,0 +1,102 @@ + + + + + Credits & Rewards – Pen2Post + + + + + +
+
+ +
+ +
+

Credits & Rewards

+

+ One credit equals one page processed. Use this screen to see your balance, daily gifts, and ways to earn or buy more. +

+ +
+

Your credits

+
+

+ Current balance: + credits +

+

+ Daily gift: + Available once per day. +

+

+ Streak bonus: + Keep a 5‑day streak to earn bonus credits. +

+
+
+ +
+

Get more credits

+ +
+

Earn free credits

+

+ Watch a short rewarded ad to earn a small number of extra credits each day. +

+ +

+ Limited number of rewarded ads per day. Availability may vary by region and network. +

+
+ +
+

Buy credits

+

+ When you need more pages – for older notebooks or big projects – you can buy a one‑time credit pack. + No subscriptions or automatic renewals. +

+ +
+ + + + + +
+ +

+ All purchases are handled securely by the app store on your device. + Prices are shown there in your local currency. +

+
+
+ +
+

How credits and rewards work

+
    +
  • Every processed page uses one credit.
  • +
  • You get a small daily gift so you can keep a regular journaling habit.
  • +
  • Using the app several days in a row can unlock streak bonuses.
  • +
  • You can mix free credits, ad‑earned credits, and paid bundles.
  • +
+
+
+
+ + + + \ No newline at end of file diff --git a/feedback.html b/feedback.html new file mode 100644 index 0000000..490baf9 --- /dev/null +++ b/feedback.html @@ -0,0 +1,73 @@ + + + + + Feedback – Pen2Post + + + + + +
+
+ +
+ +
+

Feedback

+

+ I’m a solo developer. I can’t reply to everyone, but I do read all feedback and use it to improve Pen2Post. +

+ +
+

Send a quick note

+ +
+ +
+

What happens next

+

+ Feedback is collected so I can see which parts of the app are working well and where people get stuck. +

+

+ I may use anonymized feedback to guide future updates, but I won’t share your message or email publicly. +

+
+
+
+ + + + diff --git a/help.html b/help.html new file mode 100644 index 0000000..e17e3d4 --- /dev/null +++ b/help.html @@ -0,0 +1,149 @@ + + + + + Help & Tips – Pen2Post + + + + + +
+
+ +
+ +
+

Help & Tips

+

+ Answers to common questions, plus simple ways to get better results from your handwritten pages. +

+ + +

Quick questions

+ +

What is Pen2Post for?

+

+ Pen2Post turns photos of your handwritten journal pages into editable text, + while keeping your original wording and structure. +

+ +

Will it fix my spelling or grammar?

+

+ No. Pen2Post is designed to copy what is on the page, not rewrite it. + You stay in control of the meaning and final edits. +

+ +

Can I process multiple pages at once?

+

+ You process one page at a time. Each new extraction is added to the text area, + so your pages stay in the order you choose. +

+ +

How long does it take?

+

+ Most pages take under a minute, depending on how long the page is and how busy the system is. +

+ +

How many pages can I do per day?

+

+ Your daily limit depends on your credits and plan. + The app shows your current balance and will let you know when you are running low. +

+ + +

Tips for better results

+ +

Write better

+
    +
  • Write a little slower and slightly larger than your tiniest handwriting.
  • +
  • Avoid extremely loose cursive if it tends to run together.
  • +
  • If needed, try printing instead of cursive for tricky words or names.
  • +
+ +

Snap better

+
    +
  • Use good, even lighting and avoid strong shadows across the page.
  • +
  • Hold your phone steady and keep the page flat in the frame.
  • +
  • Use dark ink on light paper for strong contrast.
  • +
  • Fill most of the camera view with the page, but keep all edges visible.
  • +
+ +

Share better (backup options)

+
    +
  • + If recognition struggles with your writing, you can read your journal aloud using your + keyboard’s microphone and let built‑in speech‑to‑text handle the transcription. +
  • +
  • + You can also add quick voice notes or comments about a Pen2Post extract and clean + everything up later in your main writing app. +
  • +
+ + +

Troubleshooting

+ +

The text has lots of mistakes

+
    +
  • Check lighting and make sure there are no heavy shadows or glare on the page.
  • +
  • Try rewriting a short sample more clearly and testing again.
  • +
  • Make sure the page fills most of the photo and is in focus.
  • +
+ +

Nothing seems to happen after I tap Extract

+
    +
  • Give it a bit of time; some pages can take close to a minute when the system is busy.
  • +
  • Check your internet connection and try again if the connection was weak.
  • +
  • If the app repeatedly fails, close and reopen it, then try another page.
  • +
+ +

My credits or daily limit look wrong

+
    +
  • Wait a few seconds and check again; sometimes it takes a moment to refresh.
  • +
  • If you just watched an ad or bought credits, reopen the app to trigger a refresh.
  • +
  • If the problem continues, note roughly when it happened so you can mention it in feedback.
  • +
+ + +

Privacy and your pages

+

+ Your handwritten pages and extracted text are only processed for as long as needed to create the result. + Once the text is shown in the app, it lives on your device. +

+

+ If you clear the text or close the app without copying or sharing it, it cannot be recovered. + Before you clear anything, make sure you have copied or shared the text into a safe place. +

+ + +

Accuracy and refunds

+

+ Handwriting recognition is never perfect. Results depend on your handwriting, lighting, + and how clear the photo is. Pen2Post is meant to give you a strong rough draft, not a final, + error‑free document. +

+

+ To protect your privacy, the app does not store your writing on its servers or keep detailed + records of individual pages. Because of this, pages cannot be reviewed or “reprocessed” on request. +

+

+ Individual credits cannot be refunded based on dissatisfaction with output quality. + Please use your free daily pages and any bonus test credits to make sure Pen2Post works well enough + with your handwriting before buying larger bundles. +

+ +

+ If you still have questions after reading this page, you can send feedback from the Feedback section in the menu. + While replies are not guaranteed, your notes help improve the app over time. +

+
+
+ + + + \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..5adad4c --- /dev/null +++ b/index.html @@ -0,0 +1,67 @@ + + + + + + Journal + + + +
+ +
+ + +
+ + +
+ + + +
+ + + +
+ + + + + +
+ + + + diff --git a/journal.css b/journal.css new file mode 100644 index 0000000..7efb7de --- /dev/null +++ b/journal.css @@ -0,0 +1,266 @@ +.notes-container { + height: 100vh; + width: 100%; + background-color: #fffbf5; + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.top-buttons { + position: absolute; + top: 1rem; + right: 1rem; + display: flex; + gap: 0.5rem; + z-index: 10; +} + +.bottom-buttons { + position: absolute; + bottom: 1rem; + left: 1rem; + display: flex; + gap: 0.5rem; + z-index: 10; +} + +.floating-button { + width: 2.75rem; + height: 2.75rem; + border-radius: 9999px; + background-color: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + display: flex; + align-items: center; + justify-content: center; + border: none; + cursor: pointer; + transition: transform 0.15s ease; +} + +.floating-button:active { + transform: scale(0.95); +} + +.floating-button svg { + width: 1.25rem; + height: 1.25rem; + color: #007AFF; +} + +.side-menu { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.25); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + display: none; + align-items: flex-start; + justify-content: flex-end; + z-index: 20; +} + +.side-menu.open { + display: flex; +} + +.side-menu-content { + width: 260px; + max-width: 80%; + background: #fffbf5; + box-shadow: -8px 0 20px rgba(0, 0, 0, 0.1); + padding: 1.25rem 1rem 1.5rem 1.25rem; + border-radius: 16px 0 0 16px; + margin-top: 3.5rem; + margin-right: 0.75rem; +} + +.side-menu-close { + border: none; + background: transparent; + font-size: 1.4rem; + cursor: pointer; + margin-left: auto; + display: block; +} + +.side-menu-nav { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.side-menu-nav a { + text-decoration: none; + color: #1f2933; + font-size: 0.95rem; + padding: 0.45rem 0.25rem; + border-radius: 6px; +} + +.side-menu-nav a:hover { + background: rgba(0, 122, 255, 0.06); +} + +.notes-textarea { + flex: 1; + width: 100%; + padding: 1.5rem; + background-color: transparent; + resize: none; + outline: none; + border: none; + font-size: 17px; + line-height: 1.625; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +.notes-textarea::placeholder { + color: #9ca3af; +} + +/* Toast notifications */ +.toast-container { + position: fixed; + top: 5rem; + left: 50%; + transform: translateX(-50%); + z-index: 1000; + display: flex; + flex-direction: column; + gap: 0.5rem; + pointer-events: none; +} + +.toast { + background-color: rgba(0, 0, 0, 0.85); + color: white; + padding: 0.75rem 1.5rem; + border-radius: 8px; + font-size: 14px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + opacity: 0; + transform: translateY(-20px); + transition: all 0.3s ease; +} + +.toast.show { + opacity: 1; + transform: translateY(0); +} + +.toast-success { + background-color: rgba(52, 199, 89, 0.95); +} + +.toast-error { + background-color: rgba(255, 59, 48, 0.95); +} + +.toast-info { + background-color: rgba(0, 122, 255, 0.95); +} + +.page-main { + flex: 1; + padding: 1.5rem; + overflow-y: auto; +} + +.card { + border-radius: 12px; + background: #fffaf2; + border: 1px solid #e5d6bf; + padding: 1rem; + margin-top: 0.5rem; +} + +.card-info { + background: #f0fbff; + border-color: #cbe4ff; +} + +.card-warn { + background: #fff8f2; + border-color: #f3d3b2; +} + +.btn-primary { + padding: 0.6rem 1.2rem; + border-radius: 999px; + border: none; + background: #007aff; + color: #fff; + font-size: 0.95rem; + cursor: pointer; +} + +.btn-outline { + padding: 0.6rem 1.2rem; + border-radius: 8px; + border: 1px solid #e5d6bf; + background: #ffffff; + text-align: left; + cursor: pointer; +} + +.text-muted { + color: #555; + font-size: 0.95rem; +} + +.feedback-form { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 1rem; +} + +.feedback-textarea { + width: 100%; + min-height: 140px; + padding: 0.75rem 1rem; + border-radius: 8px; + border: 1px solid #e5d6bf; + background: #fffaf2; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 0.95rem; + resize: vertical; +} + +.feedback-textarea::placeholder { + color: #9ca3af; +} + +.feedback-email-input { + width: 100%; + padding: 0.6rem 0.9rem; + border-radius: 8px; + border: 1px solid #e5d6bf; + background: #ffffff; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 0.95rem; +} + +.feedback-actions { + display: flex; + gap: 0.5rem; + align-items: center; + margin-top: 0.25rem; +} + +.btn-secondary { + padding: 0.6rem 1.2rem; + border-radius: 999px; + border: 1px solid #e5d6bf; + background: #ffffff; + color: #333; + font-size: 0.95rem; + cursor: pointer; +} + diff --git a/journal.js b/journal.js new file mode 100644 index 0000000..1da4495 --- /dev/null +++ b/journal.js @@ -0,0 +1,315 @@ +// Journal UI (Figma skin) + Prometheus HTR extraction wiring +// - Camera button => opens system image chooser, posts to /api/extract +// - Clear => clears text + resets page counter +// - Save => saves to localStorage +// - Share => native share or clipboard +// +// Assumes your server accepts JSON: { image: "" } at POST /api/extract +// (matches your original working prototype). + +// ------------------------- +// Config +// ------------------------- +const API_BASE = window.location.origin; +const MAX_IMAGE_DIMENSION = 1600; + +// ------------------------- +// DOM +// ------------------------- +const noteTextarea = document.getElementById('noteTextarea'); +const shareBtn = document.getElementById('shareBtn'); +const menuBtn = document.getElementById('menuBtn'); +const cameraBtn = document.getElementById('cameraBtn'); +const clearBtn = document.getElementById('clearBtn'); +const saveBtn = document.getElementById('saveBtn'); +const toastContainer = document.getElementById('toastContainer'); +const fileInput = document.getElementById('fileInput'); + +// ------------------------- +// State +// ------------------------- +let pageCount = 0; +let isProcessing = false; + +// ------------------------- +// Toasts +// ------------------------- +function showToast(message, type = 'info') { + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + + toastContainer.appendChild(toast); + + // Trigger animation + setTimeout(() => toast.classList.add('show'), 10); + + // Remove toast after 3 seconds + setTimeout(() => { + toast.classList.remove('show'); + setTimeout(() => toast.remove(), 300); + }, 3000); +} + +// ------------------------- +// Helpers +// ------------------------- +function setBusy(busy) { + isProcessing = busy; + cameraBtn.disabled = busy; + clearBtn.disabled = busy; + saveBtn.disabled = busy; + shareBtn.disabled = busy; + cameraBtn.style.opacity = busy ? '0.6' : ''; +} + +function appendExtractedText(newText) { + const text = (newText || '').trim(); + if (!text) { + showToast('No text returned', 'info'); + return; + } + + pageCount += 1; + + const current = noteTextarea.value.trim(); + if (current) { + noteTextarea.value = current + `\n\n--- Page ${pageCount} ---\n\n` + text; + } else { + noteTextarea.value = text; + } + + // Keep cursor at end + noteTextarea.scrollTop = noteTextarea.scrollHeight; +} + +// Resize image -> base64 (no data: prefix) +function resizeImageToBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (e) => { + const img = new Image(); + + img.onload = () => { + const canvas = document.createElement('canvas'); + let { width, height } = img; + + if (width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION) { + if (width > height) { + height = Math.round((height * MAX_IMAGE_DIMENSION) / width); + width = MAX_IMAGE_DIMENSION; + } else { + width = Math.round((width * MAX_IMAGE_DIMENSION) / height); + height = MAX_IMAGE_DIMENSION; + } + } + + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0, width, height); + + const dataUrl = canvas.toDataURL('image/jpeg', 0.85); + const base64 = dataUrl.split(',')[1]; + resolve(base64); + }; + + img.onerror = () => reject(new Error('Failed to load image')); + img.src = e.target.result; + }; + + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsDataURL(file); + }); +} + +// ------------------------- +// Extraction pipeline +// ------------------------- +async function extractFromImageFile(file) { + if (isProcessing) return; + + setBusy(true); + showToast('Extracting…', 'info'); + + try { + const resizedBase64 = await resizeImageToBase64(file); + + const response = await fetch(`${API_BASE}/api/extract`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ image: resizedBase64 }) + }); + + if (!response.ok) { + let msg = `HTTP ${response.status}`; + try { + const err = await response.json(); + msg = err?.detail?.message || err?.detail || err?.message || msg; + } catch (_) { + // ignore json parse + try { msg = await response.text(); } catch (_) {} + } + throw new Error(msg || 'Extraction failed'); + } + + const result = await response.json(); + appendExtractedText(result.text); + showToast(`Page extracted (${pageCount})`, 'success'); + } catch (err) { + console.error(err); + showToast(err?.message ? `Extract failed: ${err.message}` : 'Extract failed', 'error'); + } finally { + setBusy(false); + } +} + +// ------------------------- +// Button wiring +// ------------------------- + +// Share +shareBtn.addEventListener('click', async () => { + const text = noteTextarea.value.trim(); + + if (!text) { + showToast('Nothing to share', 'error'); + return; + } + + if (navigator.share) { + try { + await navigator.share({ text }); + // no toast needed; iOS share sheet is the feedback + } catch (err) { + // user cancelled is fine; only show for real failures + if (err?.name !== 'AbortError') showToast('Failed to share', 'error'); + } + return; + } + + try { + await navigator.clipboard.writeText(text); + showToast('Copied to clipboard', 'success'); + } catch (err) { + showToast('Clipboard blocked (long-press to copy)', 'error'); + } +}); + +// Menu (Hamburger Menu - potential future addition skin-picker?) +menuBtn.addEventListener('click', () => { + window.location.href = 'menu.html'; +}); + +// Extract from image +cameraBtn.addEventListener('click', () => { + if (isProcessing) return; + // Allow selecting same photo twice + fileInput.value = ''; + fileInput.click(); +}); + +fileInput.addEventListener('change', async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + // reset input so selecting the same file again works + e.target.value = ''; + await extractFromImageFile(file); +}); + +// Clear +clearBtn.addEventListener('click', () => { + const text = noteTextarea.value.trim(); + if (!text) { + // Nothing to clear; no need to warn. + return; + } + + const ok = window.confirm( + 'Clear all text?\n\nThis cannot be undone. Make sure you have saved or shared anything important first.' + ); + + if (!ok) { + return; + } + + noteTextarea.value = ''; + pageCount = 0; + localStorage.removeItem('savedNote'); + localStorage.removeItem('savedNotePageCount'); + showToast('Cleared', 'success'); +}); + +// Save - localStorage +saveBtn.addEventListener('click', () => { + const text = noteTextarea.value.trim(); + if (!text) { + showToast('Nothing to save', 'error'); + return; + } + localStorage.setItem('savedNote', text); + localStorage.setItem('savedNotePageCount', String(pageCount)); + showToast('Saved', 'success'); +}); + +// Load saved note on page load +window.addEventListener('DOMContentLoaded', () => { + const savedNote = localStorage.getItem('savedNote'); + const savedCount = parseInt(localStorage.getItem('savedNotePageCount') || '0', 10); + + if (savedNote) { + noteTextarea.value = savedNote; + pageCount = Number.isFinite(savedCount) ? savedCount : 0; + showToast('Loaded saved note', 'info'); + } +}); + +// Navigation back to main app +function goBackToApp() { + window.location.href = "index.html"; // change if your main file has a different name/path +} + +// Credits & Rewards actions +function onWatchAd() { + // TODO: integrate with your AdMob rewarded flow + // Example: AdMob.showRewardedAd().then(refreshCredits); + console.log("Watch-ad clicked – call AdMob flow here."); +} + +function onBuyPack(packId) { + // TODO: integrate with your in-app purchase / RevenueCat / store kit flow + console.log("Buy-pack clicked:", packId); + // Example: Purchases.purchasePackage(packId); +} + +// Feedback handling +function submitFeedback() { + const messageEl = document.getElementById('feedbackMessage'); + const emailEl = document.getElementById('feedbackEmail'); + + if (!messageEl) return; + + const message = messageEl.value.trim(); + const email = emailEl ? emailEl.value.trim() : ''; + + if (!message) { + showToast('Please add a short message before sending.', 'error'); + return; + } + + // For MVP: open mail client with a prefilled email. + const to = 'support@prometheus.cafe'; // change to your actual inbox + const subject = encodeURIComponent('Pen2Post feedback'); + const bodyLines = [ + message, + '', + email ? `Reply email (optional): ${email}` : '', + ]; + const body = encodeURIComponent(bodyLines.join('\n')); + + window.location.href = `mailto:${to}?subject=${subject}&body=${body}`; + + showToast('Opening your mail app to send feedback…', 'info'); +} diff --git a/legal.html b/legal.html new file mode 100644 index 0000000..1b8f4cd --- /dev/null +++ b/legal.html @@ -0,0 +1,137 @@ + + + + + Legal Stuff – Pen2Post + + + + + +
+
+ +
+ +
+

Legal Stuff

+

+ A plain‑language summary of how Pen2Post treats your data and what you can expect when you use the app. +

+ + +
+

Privacy Policy

+ +

What data Pen2Post uses

+

+ Pen2Post processes images of your handwritten pages to turn them into text. + The images are sent securely to the server, processed, and then discarded once the text is created. +

+

+ The extracted text is shown only inside the app on your device. + It is not stored on the server after processing. +

+ +

What is not stored

+

+ Pen2Post does not keep a copy of your pages or your text on its servers after the result is shown. + It also does not maintain detailed per‑page history of what you wrote. +

+

+ This means your writing stays private, but it also means that if you clear the text or close the app + without copying or sharing it, it cannot be recovered later. +

+ +

How you should save your work

+

+ Before you tap Clear or leave the app, make sure you have copied or shared your text + to a safe place such as a notes app, email, or cloud drive. +

+

+ Pen2Post does not offer its own cloud storage or document history. + Saving and backing up your work is handled by the other apps and services you choose. +

+ +

Other information

+

+ Pen2Post may store basic account and usage information needed to manage your credits, + daily gifts, and streak bonuses, but not the content of your writing. +

+

+ If you use rewarded ads or make purchases, the ad networks and app stores may collect their own data + under their own privacy policies. +

+
+ + +
+

Terms of Use

+ +

What Pen2Post offers

+

+ Pen2Post is a handwriting recognition tool that helps you turn handwritten pages into editable text. + It is meant to give you a strong rough draft, not a perfect, error‑free document. +

+ +

Accuracy and limitations

+

+ Handwriting recognition is never perfect. Results depend on your handwriting style, lighting, + and how clear the photo is. +

+

+ You are responsible for reviewing and editing the text before you share it or publish it. + Pen2Post does not guarantee accuracy and is not responsible for errors in the output. +

+ +

Credits, refunds, and billing

+

+ The app uses credits. One credit usually equals one processed page. + You can get free daily credits, earn some through rewarded ads, and buy bundles when you need more. +

+

+ To protect your privacy, Pen2Post does not store the content of your pages or keep detailed per‑page history. + Because of this, individual pages cannot be reviewed or reprocessed on request, and individual credits + cannot be refunded based on dissatisfaction with output quality. +

+

+ All payments are handled by the Apple App Store or Google Play Store. + Pen2Post does not see or store your full payment details and cannot change or reverse store‑level transactions. +

+ +

Fair use and availability

+

+ Pen2Post is provided “as is” and may change or be updated over time. + Reasonable limits on daily use, free credits, ads, and bundles may apply and can change as the service evolves. +

+

+ Misuse of the app, attempts to bypass limits, or abusive behavior may result in restricted access. +

+ +

Support

+

+ Pen2Post is a one‑person project. While technical issues are taken seriously, + support responses may be delayed and cannot be guaranteed in every case. +

+

+ For billing issues such as failed charges or payment method problems, please contact the app store directly. +

+
+ +
+

Questions

+

+ If you have concerns about privacy or terms, you can use the Feedback option in the menu to share them. + While individual replies are not guaranteed, feedback is reviewed to help improve the app over time. +

+
+
+
+ + + + \ No newline at end of file diff --git a/menu.html b/menu.html new file mode 100644 index 0000000..c6e7023 --- /dev/null +++ b/menu.html @@ -0,0 +1,34 @@ + + + + + Menu – Pen2Post + + + + +
+
+ +
+ +
+

Menu

+ +
+
+ + + + \ No newline at end of file diff --git a/screen.jpg b/screen.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6441f566bdc04ac6613ff8357e155cefe01befc1 GIT binary patch literal 8186 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 rl+0jBia8q{t1X@Gn + + + + Quick Start – Pen2Post + + + + + +
+
+ +
+ +
+

Quick Start

+

+ A short guide to go from a handwritten page to editable text in a minute or two. +

+ +

5 steps: page to post

+
    +
  1. + Write – Fill a notebook page the way you normally journal. No special paper is required. +
  2. +
  3. + Snap – Open Pen2Post and tap the camera button at the bottom left + to take a photo of your page, or choose an existing photo if your device offers that option. +
  4. +
  5. + Extract – After the image is loaded, tap the main Extract Text button + (in the main app screen) and wait while your handwriting is turned into text. +
  6. +
  7. + Shape – Read through the text in the big writing area, fix any misread words, + and make sure it matches your journal page. +
  8. +
  9. + Share – Tap the round share button at the top right to send the text + into your notes app, email, or any other app your phone suggests. +
  10. +
+ +

+ Pen2Post gives you a good rough draft of what you wrote. You stay in control of the meaning, + and you make the final edits. +

+ +

Main buttons on the screen

+
    +
  • Top right – Share: sends the text to other apps on your device using the system share sheet.
  • +
  • Top right – Menu: opens options like About, Quick Start, Help & Tips, Credits & Rewards, Legal Stuff, and Feedback.
  • +
  • Bottom left – Camera: lets you capture a new photo of your handwritten page.
  • +
  • Bottom center – Clear: clears the current text and lets you start fresh. Use this only after you have saved or shared your text.
  • +
  • Bottom right – Save/Copy: copies the text or triggers saving, depending on how your device is set up, so you do not lose your work.
  • +
+ +

What a typical session looks like

+

+ You write a page in your journal, open Pen2Post, tap the camera button, and snap a clear photo. + You tap Extract Text, wait a short moment, fix a few words, then use the share button to send the + result into your favorite writing or notes app. +

+ +

App screen preview

+

+ The screenshot below shows the main layout: the large writing area, the share and menu buttons at the top, + and the camera, clear, and save buttons along the bottom. +

+ +
+ + Pen2Post main screen +
+ +

Tips for your first page

+
    +
  • Use good, even lighting and avoid strong shadows on the page.
  • +
  • Write a little larger and clearer than your tiniest handwriting.
  • +
  • Use dark ink on light paper for best results.
  • +
+ +

+ Once you are comfortable with a single page, repeat the same steps for the rest of your notebook. +

+
+
+ + + + \ No newline at end of file