Skip to content

Latest commit

 

History

History
1050 lines (855 loc) · 82.2 KB

File metadata and controls

1050 lines (855 loc) · 82.2 KB

linscanner API reference

Generated by tools/gen_api_docs.py from the source. Don't edit by hand; re-run the tool.

src/main.py

LinScanner Universal document scanner for Linux (SANE). Entry point.

Symbol Purpose
parse_args(argv) Parse command-line options (--version, --test-scanner, --page, --quit-after, --debug)
main(argv=None) Build services and the window, run the GTK loop, clean up temp scans on exit

src/config/config_layout.py

Layout Configuration Centralized layout dimensions and spacing constants

Symbol Purpose
class Dimensions() Layout dimension constants
class Spacing() Spacing constants
class Layout() Main layout configuration

src/config/config_scan.py

Scan Configuration Colour modes, quality presets, paper sizes and export formats. Everything here is scanner-independent; backends map these onto what a device supports.

Constants: NETWORK_SCANNING, COLOR_MODES, LINEART_MODE_NAMES, BW_STYLES, DEFAULT_BW_STYLE, QUALITY_PRESETS, DEFAULT_QUALITY, STANDARD_RESOLUTIONS, PAPER_SIZES, DEFAULT_PAPER, FEEDER_SOURCE_HINTS, DUPLEX_SOURCE_HINTS, SHEET_MODES, DEFAULT_SHEET_MODE, PREFERRED_BACKENDS, HIDDEN_BACKENDS_BY_DEFAULT, BACKEND_EXTRA_ARGS, EXPORT_FORMATS, JPEG_QUALITY, LIST_TIMEOUT, OPTIONS_TIMEOUT, PAGE_TIMEOUT

src/config/config_themes.py

Theme Definitions The seven dark themes of gtk-python-dashboard-starter. The default is the framework's standard "Default Blue" (as in its dashboard.png), completed with the framework's own palette from its config_theme.py. Nord is completed from the official Nord palette.

Constants: DEFAULT_THEME_ID, DARK_THEMES

Symbol Purpose
class ThemeDefinition() Single theme definition (colours used to generate the app CSS)
  .__init__(self, name, accent_color, sidebar_bg, window_bg, hover_color, card_bg=None, raised_bg=None, accent_text='#ffffff', text_primary='#eeeeee', text_secondary='#d0d0d0', text_muted='#9a9a9a', success='#27ae60', error='#cc3333') Store theme colours; optional ones default from the core four
get_theme(theme_id) Get theme by ID (falls back to the default theme)
get_all_themes() Get all available themes

src/backends/backend_base.py

Scanner Backend Interface Every scanner backend (SANE today; others can be added) implements this interface, so the UI never depends on a specific driver technology.

Constants: USER_ACTION_CODES, FALLTHROUGH_CODES, SANE_EXIT_CODES

Symbol Purpose
class ScannerDevice() A scanner as reported by a backend
  .label(self) Display name: vendor, model and the SANE driver in brackets
class ScannerCapabilities() What a device can do, normalised from its options
class ScanRequest() One scan job, already resolved to device-specific values
class ScanError(Exception) Raised when listing, probing or scanning fails (message is user-facing)
  .__init__(self, message, code='error') message: shown to the user; code: see USER_ACTION_CODES / FALLTHROUGH_CODES
  .needs_user(self) True if the user must act (feeder empty, jam, cover open)
class ScannerBackend(ABC) Interface all scanner backends implement
  .available(self) True if the backend's tools are installed
  .list_devices(self) Return [ScannerDevice]; may take several seconds
  .get_capabilities(self, device_id) Return ScannerCapabilities for a device
  .scan(self, request, on_page=None, on_progress=None, cancel_event=None) Run a scan; return list of image paths.

src/backends/backend_escl.py

Direct eSCL Backend LinScanner's own driverless client for eSCL (AirScan / Mopria), used when SANE can't reach a scanner that speaks eSCL. Covers: - IPP-over-USB devices (USB class 07/01/04) exposed by ipp-usb on http://127.0.0.1:60000+ (loopback), and - network scanners announced over mDNS as _uscan._tcp / _uscans._tcp. Standard library only (urllib + ElementTree). Protocol notes: docs/research/device-capabilities.md §2.

Constants: PREFIX, IPP_USB_PORTS, HTTP_TIMEOUT, NS, MODE_TO_NAME, NAME_TO_MODE, STATUS_CODES

Symbol Purpose
ipp_usb_urls() eSCL base URLs of IPP-over-USB devices: ipp-usb listens on 127.0.0.1:60000+
is_loopback_url(url) True if url points at this computer (127.0.0.1 / localhost / ::1)
_local(tag) Tag name without its XML namespace
_find(elem, name) First descendant with this local name (namespace-agnostic)
_findall(elem, name) All descendants with this local name
_text(elem, name, default='') Text of the first descendant with this local name
parse_capabilities(xml_text) ScannerCapabilities XML -> (ScannerCapabilities, info dict)
parse_status(xml_text) ScannerStatus XML -> (state, adf_state)
build_scan_settings(request, fmt) ScanSettings XML for a request (regions in 1/300 inch)
class EsclBackend(ScannerBackend) eSCL over HTTP, without SANE
  .__init__(self, extra_urls=None, probe_ipp_usb=True, browse_mdns=True, network=True) extra_urls: known eSCL base URLs (e.g. http://192.168.1.20/eSCL);
  .available(self) Always available: needs only the Python standard library
  ._request(url, method='GET', data=None, timeout=HTTP_TIMEOUT) (status, headers, body) for an HTTP request; ScanError on network errors
  ._candidate_urls(self) eSCL base URLs from ipp-usb loopback ports, mDNS and configured URLs
  .list_devices(self) eSCL scanners that answer ScannerCapabilities
  .get_capabilities(self, device_id) Capabilities from GET ScannerCapabilities (cached per URL)
  .get_status(self, device_id) (state, adf_state) from GET ScannerStatus
  .scan(self, request, on_page=None, on_progress=None, cancel_event=None) POST a scan job, then fetch NextDocument until the job is done
  .explain(adf_state) User-facing message for an eSCL AdfState

src/backends/backend_sane.py

SANE Backend Talks to scanners through SANE's scanimage tool, so any scanner with a SANE driver works: USB backends (epsonds, genesys, ...), network scanners via sane-airscan (eSCL/WSD), HP via hpaio, and SANE's virtual "test" scanner.

Constants: DEVICE_LOCK, NET_LINE_CONFIGS, NETWORK_ONLY_DRIVERS

Symbol Purpose
usb_only_config(folder) Switch off network discovery in a private SANE config folder.
refresh_airscan_devices(folder) airscan.conf with discovery off, listing only IPP-over-USB devices on 127.0.0.1
class SaneBackend(ScannerBackend) SANE via the scanimage command-line tool
  .__init__(self, only_backends=None, network=NETWORK_SCANNING) only_backends: restrict SANE to these drivers (e.g. ["test"]); used by
  .close(self) Delete the private SANE config dir created for only_backends
  .available(self) True if SANE's scanimage tool is installed
  ._run(self, args, timeout) Run scanimage with args; raises ScanError on missing tool or timeout
  .list_devices(self) List scanners SANE can see (scanimage -f), as ScannerDevice objects
  .get_capabilities(self, device_id) Read a device's options (scanimage -A) as ScannerCapabilities
  .build_command(self, request) scanimage arguments for a request (separate for testing)
  .scan(self, request, on_page=None, on_progress=None, cancel_event=None) Scan per request; report pages/progress live; honour cancel; return page paths
  ._scan_locked(self, request, on_page, on_progress, cancel_event) scan() body; runs with DEVICE_LOCK held
  .classify(returncode, stderr) Error code from scanimage's exit status (= SANE status), falling back to its text
  ._explain(stderr) Turn scanimage's error output into a user-facing message

src/backends/parser_sane.py

SANE Output Parser Pure text parsing of scanimage output (no subprocesses) so it can be unit-tested against captured fixtures.

Constants: LIST_FORMAT, _SERIAL, _OPTION, _GROUP, _FLAGS, FEATURE_OPTIONS, _RANGE

Symbol Purpose
redact(text) Hide long hex serial numbers some backends put in device names
parse_device_list(text) Parse `scanimage -f '%d
model_key(device) Normalised model name used to spot one scanner offered by two backends
parse_options(text) Parse scanimage -d DEV -A output into {option: {values, default, unit}}
device_features(options) Friendly names of notable device features found among its options
_resolutions(opt) Resolution list from a parsed option (lists as-is, ranges as standard steps)
_max(opt) Upper bound of a range option (0.0 if not a range)
capabilities_from_options(options) Normalise parsed options into ScannerCapabilities
parse_progress(text) Return the last 'Progress: 42.3%' percentage in a chunk of stderr, or None

src/backends/usb_probe.py

USB Probe Driver-independent USB facts for scanner detection, read straight from sysfs and the udev database (no subprocesses, no root): identity, speed, interface classes, device-node access and SANE's udev match. See docs/research/device-capabilities.md §5.

Constants: SYS_USB, UDEV_DATA, SCANNER_VENDORS

Symbol Purpose
class UsbDevice() One USB device and what its interfaces suggest
  .usb_id(self) VID:PID string
  .libusb_name(self) How SANE names this device: libusb:BBB:DDD
  .has_class(self, cls, sub=None, proto=None) True if any interface matches the class (and optional subclass/protocol)
  .kinds(self) Why this device may be a scanner (empty list = probably not one)
_read(path, default='') Contents of a small sysfs/udev file, stripped (default if unreadable)
_udev_properties(sys_path) E: properties from the udev database for a device (empty if unavailable)
probe(sys_root=SYS_USB) All USB devices (not hubs' interfaces) with scanner-relevant facts
likely_scanners(devices) USB devices that are probably scanners (or scanner-capable MFPs)
speed_label(mbps) Human USB speed name

src/modules/app_context.py

App Context Shared services handed to every page (settings, scan manager, navigation, theme) plus a tiny event hub so pages can react to each other without importing each other. Events: "pages-changed", "devices-changed", "settings-changed", "theme-changed"

Symbol Purpose
class AppContext() Service container + publish/subscribe event hub
  .__init__(self, settings, scan_manager, nav_manager, theme_applicator) Hold the shared services; window is set later by AppWindow
  .on(self, event, callback) Subscribe callback to an event name
  .emit(self, event, *args) Call every subscriber of event with args

src/modules/manager_connection.py

Connection Engine Heuristic, multi-method scanner connection (docs/research/connection-methods.md). 1. Discovery asks every backend (SANE, direct eSCL) plus the USB probe what it sees, and groups entries that are the same physical scanner. 2. Each physical scanner gets its connection methods ranked: A1 open-source SANE driver (USB) e.g. epsonds, pixma, genesys A3 vendor SANE driver e.g. epsonscan2, hpaio, brother* B2 driverless eSCL over IPP-USB SANE airscan/escl on 127.0.0.1 D1 LinScanner's own eSCL client backend_escl (USB or network) B1 driverless network eSCL / WSD SANE airscan/escl C1 remote SANE (saned) SANE net T SANE virtual test scanner 3. A scan tries method 1; on a connection-type failure (busy, I/O, timeout, access, unsupported, missing driver) it tries the next, and so on. It never falls through when the user must act (feeder empty, jam, cover open), since another method on the same device would fail or double-feed.

Constants: METHOD_ORDER, METHOD_LABELS, VENDOR_SANE_BACKENDS, VENDOR_SANE_PREFIXES

Symbol Purpose
method_code(device) Connection method code (A1/A3/B1/B2/C1/D1/T) for a backend device entry
class Method() One way to reach a physical scanner
  .label(self) e.g. 'Open-source SANE driver · epsonds'
class PhysicalDevice() A scanner, with every method that can reach it (best first)
  .id(self) Stable id for the UI: the preferred method's device id (or the key)
  .backend(self) Driver name of the preferred method
  .kind(self) Device type text from the preferred method
  .label(self) Display name with the driver in brackets
_usb_for(device, usb_devices) UsbDevice matching a SANE libusb:BBB:DDD device name, if any
_escl_scanner_on_usb() True when some device on ipp-usb really offers eSCL scanning (an MFP, not a printer)
class ConnectionEngine() Discovers physical scanners and scans with fallback across methods
  .__init__(self, backends, use_usb_probe=True) backends: ScannerBackend instances (e.g. SaneBackend(), EsclBackend())
  .discover(self) List physical scanners (grouped), with ranked methods
  ._printer_without_scanner(u) True for a plain printer: it speaks IPP-USB (like scanner-capable MFPs) but offers no
  ._hint(u) Why a USB scanner-like device has no working method, and what to do
  .scan(self, physical, build_request, on_page=None, on_progress=None, cancel_event=None) Try each method in order until one scans.

src/modules/manager_device_info.py

Device Information Builds the "Scan Devices Found" content for a physical scanner: identity, connection path, connection methods (fallback order), permissions, capabilities, live status and firmware. No GTK: returns plain data. See docs/research/device-capabilities.md §8.

Constants: STATUS_TEXT

Symbol Purpose
sections(physical, caps=None) [(section title, [(label, value), ...])] describing a physical scanner
_capability_rows(caps) [(label, value)] describing what a scanner can do
check_status(physical) (level, text) for the scanner's current state: ok / warn / busy / error
firmware(physical) Firmware / version string if a driver reports one, else a short explanation
usb_node_hint(physical) Extra advice when the USB device node isn't accessible

src/modules/manager_documents.py

Documents Manager - Recent documents: every file LinScanner saves is remembered (newest first) in ~/.local/share/linscanner/recent.json, for the Recent page. Only the path, time, page count and format are stored; the list stays on this computer. - Opening a document: a saved PDF (rendered with Ghostscript) or image file (PNG, JPEG, TIFF, multi-page TIFF) becomes pages again, so it can be checked, edited with Quick Edit and saved.

Constants: RECENT_MAX, OPEN_DPI, OPENABLE

Symbol Purpose
recent_path() The recent-documents list file
recent_entries(existing_only=True) Recent documents, newest first: [{path, saved_at, pages, format}]
add_recent(paths, pages, fmt) Remember saved files (the newest go first; a re-saved file moves to the top)
rename_recent(old_path, new_path) Follow a renamed file in the list (the entry keeps its date and page count)
forget_recent(path) Remove one file from the recent list (the file itself is not touched)
clear_recent(older_than_days=None) Empty the recent list, or drop entries saved more than N days ago; returns how many were removed.
_saved_ts(entry) When an entry was saved (epoch seconds; 0 if unknown)
safe_name(name, limit=60) A page or file name a user typed, made safe for a file system (empty if nothing is left)
crop_name(pages, source_name) The next free 'crop n' for that source page ('crop 1', 'crop 2', ...)
open_document(path, out_dir) Pages for a saved document: [{"path", "rotation", "dpi", "mode"}].
_open_pdf(path, target) Render every PDF page to PNG with Ghostscript
_open_image(path, target) Copy each frame of an image file to PNG

src/modules/manager_export.py

Export Manager Saves scanned pages (with their rotation applied) as PDF, PNG, JPEG or TIFF. Multi-page formats (PDF, TIFF) get one file; single-page formats get one file per page, numbered when there is more than one.

Symbol Purpose
load_page(page) Open a page image with its rotation and Quick Edit overlays applied
format_for_path(path) Export format key from a filename's extension (None if unknown)
export_pages(pages, path, fmt=None, registry=None) Write pages to path; returns the list of files written.
_export_document(pages, path, fmt, registry=None) Write one document (list of pages) in one format

src/modules/manager_navigation.py

Navigation Manager Handles page routing and navigation state

Symbol Purpose
class NavigationManager() Manages navigation state and page switching
  .__init__(self) Initialize navigation manager
  .register_page(self, page_id, page_widget) Register a page with the navigation system
  .set_page_stack(self, stack) Set the GTK Stack widget for page switching
  .navigate_to(self, page_id) Navigate to a specific page
  .on_navigate(self, callback) Register a callback for navigation events
  .get_current_page(self) Get current page identifier
  .get_page_widget(self, page_id) Get widget for a specific page

src/modules/manager_scan.py

Scan Manager Turns the user's choices (Color / Black & White, High / Medium / Low, paper) into a device-specific ScanRequest, and runs backend calls off the GTK thread.

Constants: MAIN_DOC, AUTO_SIZE_OPTIONS

Symbol Purpose
pick_mode(device_modes, color_mode, bw_style) Map "color" / "bw" onto one of the device's own mode names
pick_resolution(resolutions, quality) Nearest supported resolution to the quality preset (ties go higher)
paper_spec(paper) The PAPER_SIZES entry for a key (unknown keys, e.g. from old settings: Auto-Detect)
pick_area(caps, paper) Paper size clamped to the device's maximum area; (0, 0) = device default (the whole area).
auto_size_args(caps) Driver options that switch on the scanner's own paper-size detection (if it has any)
scan_types(sources) The user's Scan Type choices for a device's sources: {"front": src, "both": src, "flatbed": src}.
is_feeder(source) True if a source name means a document feeder (scan until empty)
is_duplex(source) True if a source scans both sides of each sheet
sheet_limits(source, sheet_mode) (multi_page, max_pages) for a source and sheet mode ("all" = Multi-Page / "one" = Single Page).
separate_documents(source, sheet_mode) (separate, pages per sheet): Single Page makes each sheet (front + back for duplex) a document
filter_devices(devices, show_all=False) Hide SANE's test scanner and duplicate backends for the same model
_page_notes(page) What the page processors recorded on a page, for the log
equivalent_source(source, caps) The same kind of source (flatbed / feeder / duplex) in another method's names
class ScanManager() Owns the connection engine, the current device and the scanned pages of a session
  .__init__(self, settings, backend=None, engine=None) Create the manager with a session temp dir.
  ._in_thread(self, work, on_done, on_error) Run work() in a thread; deliver result or error on the GTK thread
  .refresh_devices(self, on_done, on_error) List scanners in the background, filtered for display
  .remember_device(self, physical) Store the scanner in use, so the next start can reach it without a full search
  .restore_device(self, on_done, on_error) Reach the remembered scanner directly (a few seconds instead of a full search).
  .load_capabilities(self, device_id, on_done, on_error) Read (or reuse cached) device capabilities in the background
  .physical(self, device_id) The PhysicalDevice with this id, or None
  .build_request(self, device_id, source, color_mode, quality, paper, create_dir=True, sheet_mode='all') create_dir=False builds the request for display only (no temp folder)
  ._request_for(self, device_id, caps, source, color_mode, quality, paper, sheet_mode, create_dir=True) ScanRequest for one device/method from the user's choices and its capabilities
  .start_scan(self, request, on_page, on_progress, on_done, on_error) Start a scan in the background; pages are appended as they arrive
  .detect_page(self, page, nominal_mm=()) Auto-Detect: crop a page to the paper (in the scan thread); the file is replaced
  .process_page(self, page) Run the enabled page processors on a scanned page (in the scan thread).
  .cancel(self) Ask the running scan to stop (pages so far are kept)
  .rotate_page(self, index, degrees) Rotate a page clockwise by degrees (applied at display/export)
  .delete_page(self, index) Remove a page from the session
  .clear_pages(self) Remove all pages from the session (the next Save starts a new document)
  .document(self) The main document's file ({"path", "format"}) or None (single-document sessions)
  .document(self, value)
  .doc_ids(self) The documents in page order
  .doc_pages(self, doc) The pages of one document
  ._signature(self, doc=None) What the pages (of one document) look like now: files, versions, rotation, edits, order
  .mark_saved(self, doc=None) A document (or, with doc=None, every document) was just saved
  .doc_is_saved(self, doc) True if a document hasn't changed since it was saved
  .is_saved(self) True if every document is saved (the next Scan then starts a new one)
  .open_document(self, path) Replace the session's pages with a saved document's pages (ValueError if it can't be read)
  .cleanup(self) Delete session temp files and backend temp config
  .summary(request) One-line human description of a request (mode, dpi, size, source)

src/modules/manager_settings.py

Settings Manager Loads and saves user preferences as JSON in ~/.config/linscanner/settings.json

Constants: DEFAULTS

Symbol Purpose
default_path() Settings file path (honours XDG_CONFIG_HOME)
class SettingsManager() Dictionary-style access to persisted settings with defaults
  .__init__(self, path=None) Load settings from path (default ~/.config/linscanner/settings.json)
  .load(self) Merge stored values over defaults, accepting only known keys of the right type
  .save(self) Write settings atomically (temp file + rename)
  .get(self, key) Value for key: session override, then saved value, then default
  .override(self, key, value) Use value for this session only; it is not written to disk
  .set(self, key, value) Store a value and save immediately

src/modules/manager_theme_applicator.py

Theme Applicator Generates the application CSS from a ThemeDefinition and applies it to the screen. Colours come only from the ThemeDefinition (default: the framework's Default Blue). The current layout still uses rounded cards and pill buttons; the flat, square framework layout is planned (see docs/HANDOFF.md).

Symbol Purpose
class ThemeApplicator() Applies theme colours to the application
  .__init__(self) Create the CSS provider (registered on first apply)
  .apply_theme(self, theme) Generate and apply CSS for a theme; False if the CSS fails to load
  .generate_css(t) Build the application stylesheet from a ThemeDefinition.

src/ui/app_window.py

Main Window Sidebar + content area (layout from gtk-python-dashboard-starter).

Constants: ICON_SIZES

Symbol Purpose
set_app_icon(window=None) The LinScanner icon in several sizes, for every window (panel, Alt+Tab, dialogs)
class AppWindow(Gtk.Window) Main application window
  .__init__(self, ctx) Window with sidebar and content area; registers itself as dialog parent

src/ui/components/component_preview.py

Preview Component Large view of the selected page (fit to window, or zoomed) plus a thumbnail strip that scrolls sideways and shows 1 or 2 rows. Fast page switching: pages are drawn from small display copies (utils/util_display.DisplayCache, made in the background as pages arrive), thumbnails are cached, and selecting a page only moves the highlight instead of rebuilding the strip. Zoom: the - / Fit / + buttons, Ctrl + mouse wheel, or Ctrl + plus / minus / 0; drag the zoomed page to pan.

Constants: ZOOM_STEPS, THUMB_CACHE_MAX, LARGE_CACHE_MAX

Symbol Purpose
to_pixbuf(img) Pillow RGB image -> GdkPixbuf
class PagePreview(Gtk.Box) Selected-page view + thumbnail strip; on_select(index) when the page changes
  .__init__(self, on_select=None, cache_dir=None, rows=1, on_zoom=None, label_for=None, on_rename=None) Large view (scrolled, zoomable) plus the thumbnail strip (rows: 1 or 2)
  .set_pages(self, pages, selected=None) Show a page list and select one (keeps selection if possible)
  .refresh_selected(self) Re-render after the selected page changed (rotation, Quick Edit)
  .select(self, index) Show another page (only the highlight moves; nothing is rebuilt)
  .begin_crop(self, on_done) Next drag over the page draws a crop rectangle; on_done(left, top, right, bottom) in 0..1
  .cancel_crop(self) Leave crop mode without cropping
  .cropping(self)
  ._image_area(self) The picture's rectangle inside the event box (it is centred when smaller)
  ._crop_fractions(self) The drawn rectangle as fractions of the picture, or None if it is too small
  ._draw_crop(self, _widget, cr) Dim everything outside the rectangle being dragged
  .set_rows(self, rows) 1 or 2 rows of thumbnails
  .set_zoom(self, zoom) Zoom relative to fit-to-window (1.0 = fit)
  .zoom_in(self) Next zoom step
  .zoom_out(self) Previous zoom step
  .zoom_fit(self) Fit the whole page in the window
  .zoom_fit_width(self) Fill the window's width with the page (tall pages then scroll)
  ._apply_strip_height(self) Strip tall enough for 1 or 2 rows, plus the scroll bar
  ._thumb_pixbuf(self, page) Cached thumbnail for a page (redrawn only when the page changed)
  ._thumb_button(self, i, page) Button with a page thumbnail and its number
  ._thumb_clicked(self, index, event) Double-click a thumbnail caption to rename it
  .begin_rename(self, index=None) Edit a thumbnail's caption in place (F2 or a double-click)
  ._rename_key(self, entry, event) Esc leaves the caption unchanged
  ._finish_rename(self, index, text) Store the typed caption (empty text restores the automatic one)
  ._set_thumb(self, i) Draw (or redraw) thumbnail i
  ._rebuild_strip(self) Lay out the thumbnails column by column (1 or 2 rows), from the left
  ._scroll_to_thumb(self, index) Keep the selected thumbnail visible
  ._on_resize(self, _widget, alloc) Re-render the large view after resizing (debounced)
  ._render_large(self) Render the selected page at fit x zoom (one-shot timeout)
  ._on_scroll(self, _widget, event) Ctrl + wheel zooms; the plain wheel scrolls as usual
  ._pan_start(self, _widget, event) Start dragging the zoomed page (or the crop rectangle)
  ._pan_move(self, _widget, event) Pan while dragging (or resize the crop rectangle)
  ._pan_end(self, *_) Stop panning, or finish the crop rectangle

src/ui/components/component_segmented.py

Segmented Control Pill-shaped group of mutually exclusive toggle buttons (e.g. Color | B&W).

Symbol Purpose
class SegmentedControl(Gtk.Box) Radio-style toggle buttons; on_changed(key) fires on user selection
  .__init__(self, items, active=None, on_changed=None, button_width=None) items: [(key, label)]; button_width: same width for every button (uniform rows)
  ._toggled(self, button, key) Keep exactly one button active; report user changes
  .set_active(self, key) Select a key without firing on_changed
  .get_active(self) Currently selected key

src/ui/content_area.py

Content Area Component Stack of pages, each in its own ScrolledWindow (keeps individual scroll states). Pages are registered from a list, so adding a page is a one-line change.

Constants: PAGES

Symbol Purpose
class ContentArea(Gtk.Box) Page stack registered with the navigation manager
  .__init__(self, ctx) Create every page in PAGES, add to the stack, register for navigation

src/ui/sidebar.py

Sidebar Component Fixed sidebar with logo and navigation (from gtk-python-dashboard-starter). The active button follows navigation triggered from code as well as clicks.

Constants: NAV_ITEMS, BOTTOM_ITEMS

Symbol Purpose
class Sidebar(Gtk.Box) Logo + navigation buttons
  .__init__(self, navigation_manager) Logo, navigation buttons and Settings at the bottom
  .build_logo_area(self) Logo image plus app name
  .create_nav_button(self, label, page_id, css=None) Navigation button for a page id (css: extra class for top/bottom borders)
  .on_navigated(self, page_id) Highlight the button of the page now shown

src/pages/page_about.py

About Page What LinScanner is, privacy and licence in brief, where your files are, handy shortcuts, system versions and credits. (Signature-font credits are kept in the backlog, docs/FOLLOW-UP.md #33, as requested.)

Symbol Purpose
sane_version() First line of scanimage --version, or a short status
tilde(path) A path with the home folder shown as ~
user_paths() (what, path) for every place LinScanner keeps your data
class AboutPage(BasePage) About LinScanner
  ._text(self, card, text, css='secondary', selectable=False) Add a wrapped label to a card
  ._grid(self, card, rows) Two-column key / value grid
  .build_content(self) All About sections

src/pages/page_base.py

Base Page Class Base class for all pages (from gtk-python-dashboard-starter), extended with the app context and card helpers.

Symbol Purpose
class BasePage(Gtk.Box) All pages inherit from this class and implement build_content()
  .__init__(self, ctx, spacing=None, margin=None) Apply margins/spacing, keep the app context, then build_content()
  .build_content(self) Create the page's widgets (subclasses must implement)
  .on_shown(self) Called by the navigation manager when the page becomes visible
  .label(text, css=None, xalign=0, wrap=False, selectable=False) Create a label with optional CSS classes, alignment and wrapping
  .add_title(self, text, subtitle=None) Add the page title and an optional muted subtitle
  .add_paragraph(self, text) Add wrapped secondary text
  .make_card(self, title=None) A rounded card; returns (card, inner vertical box)
  .add_card(self, title=None, expand=False) Add a card to the page and return its inner box
  .form_row(label_text, widget) Label on the left, widget on the right

src/pages/page_devices.py

Scan Devices Found Page (sidebar: Devices) Every detected scanner with identity, connection path, connection methods in fallback order, permissions, capabilities, live status and firmware. Populates automatically; "Check for devices again" re-runs detection (SANE, eSCL, USB).

Constants: LEVEL_CSS

Symbol Purpose
run_in_background(work, on_done, on_fail) Run work() in a thread; on_done(result) or on_fail(message) on the GTK thread
class DevicesPage(BasePage) Scan Devices Found
  .build_content(self) Title, check-again button, summary line and the device sections
  .on_refreshing(self) Show that detection is running
  ._summary(self, text, css) Set the summary line text and colour
  .show_devices(self, _visible=None) Rebuild one section per physical scanner
  .device_section(self, d) One scanner: a status card, then a card per section (Identity, Capabilities, …)
  ._firmware_done(self, d, fw, label) Store and show a firmware string read in the background
  .check_status(self, d, label, button) Probe the scanner in the background and show a plain-language status
  .on_shown(self) Refresh the sections when the page is opened; a remembered scanner gets a full check

src/pages/page_preview.py

Document Page Shows scanned (or opened) pages with a PDF-editor style toolbar (Phosphor icons, captions on hover), grouped by what the tools do: History undo, redo Pages add page (PDF / images), add image, duplicate, delete page, clear all Arrange rotate left / right / 180°, move left / right, reverse order Edit crop, add text, signature (Quick Edit feature) Export save this page as…, Save, Save All, Save As… View bar first / previous / next / last page, zoom out / in, fit page, fit width, thumbnails in 1 or 2 rows The groups wrap onto a second row in narrow windows. Every change to the pages can be undone (Ctrl+Z) and redone (Ctrl+Shift+Z / Ctrl+Y). - Crop: drag a rectangle over the page. What is kept becomes a new page named "crop 1", "crop 2", … after the page it came from, which is left as it is. - Names: double-click a thumbnail's caption (or F2) to name a page. That name is the file name Save uses and Save As offers. - Save: writes to the document's file (the last Save / Save As, or the file opened from Saved). A new document is saved as a PDF in the Save folder, named after its pages when they agree on one name, automatically otherwise. - Save As…: choose the name, folder and format.

Constants: HISTORY_MAX, TOOL_GROUPS, ADDABLE

Symbol Purpose
class PreviewPage(BasePage) Page viewer with editing actions, undo, zoom, Save and Save As
  .build_content(self) Toolbar groups, Save / Save As, view bar, preview and status
  ._icon(box, name, caption, action) Add an icon button with a hover caption to a box
  .tool(self, group, name, caption, action, feature_id=None) Add an icon button (with a hover caption) to a toolbar group; features pass their id
  .add_tool(self, group, button, feature_id, after=None) Add a ready-made button to a group (for feature modules), optionally right after another
  .update_feature_buttons(self, *_) Show buttons of enabled features only; hide a group left empty
  .on_shown(self) Reload pages when the page is opened
  .set_status(self, text, error=False) Status line under the preview (errors in red)
  ._snapshot(self) The pages and documents as they are now
  .checkpoint(self, snapshot=None) Remember the pages before a change (features call this too, with a snapshot taken earlier)
  ._restore(self, snap) Put a snapshot back
  .undo(self) Undo the last change to the pages
  .redo(self) Redo a change that was undone
  ._update_history_buttons(self) Undo / redo available only when there is something to undo / redo
  .on_pages_changed(self, *_) Pages changed elsewhere (a scan, an import): start a fresh history
  .changed(self) Tell other pages this page changed the pages (history is kept)
  .on_zoom(self, zoom) Show the zoom level ('Fit' or a percentage of fit)
  .on_rows_changed(self, key) 1 or 2 rows of thumbnails; remembered
  .rename_page(self, index, name) A thumbnail caption was edited: that name is also the suggested file name
  .start_crop(self) Arm the crop tool: the next drag over the page chooses what to keep
  .apply_crop(self, left, top, right, bottom) Keep the chosen part of the page (rotation and any text or signature are baked in)
  .step(self, delta) Previous / next page
  .on_key(self, _widget, event) Page keys, Home / End, zoom and undo / redo shortcuts
  .reload(self, *_) Show the session's pages and enable/disable actions
  .current_doc(self) Document id of the selected page
  .thumb_label(self, i, page) Thumbnail caption: the page's name if it has one, else 'Page n' / 'Doc d'
  .update_info(self) Show 'Page n of m · mode · dpi' (and the document and its file) for the selected page
  .rotate(self, degrees) Rotate the selected page and re-render
  .move(self, step) Move the selected page one place earlier (-1) or later (+1)
  .reverse_pages(self) Reverse the page order
  .duplicate_page(self) Insert a copy of the selected page right after it
  .delete_page(self) Delete the selected page and select its neighbour
  .clear_pages(self) Remove all pages after confirmation (Undo brings them back)
  .add_pages(self, paths=None) Add Page: insert the pages of PDFs or images after the selected page
  ._choose_files(self, title) File dialog for PDFs and images (several at once)
  .extract_page(self) Save only the selected page to a file of its own (the document is unchanged)
  ._confirm(self, title, detail) Modal OK/Cancel question; True if OK
  .open_document(self, path, quick_edit=False) Open a saved document (from Saved) as the current pages; optionally start Quick Edit
  ._doc_to_save(self) (doc id, its pages): the selected page's document (all pages when there is only one)
  ._default_path(self, n=None) A new file name in the default save location (Settings)
  .suggested_name(self, pages, ext='.pdf') File name for these pages: a name typed on a thumbnail, when it is unambiguous.
  .on_save(self) Save the document to its file; a new document goes to the default save location as a PDF
  .on_save_all(self) Save every document (Single Page sheets) as its own PDF in the default save location
  .on_save_as(self, _btn=None, pages=None, title='Save scanned document', remember=True) Save As dialog (PDF/PNG/JPEG/TIFF) for the document (or given pages), export, report
  ._write(self, pages, path, fmt, doc=None, report=True, remember=None) Export pages; with a doc id, remember its file and mark it saved; report the result

src/pages/page_recent.py

Saved Page Documents saved with LinScanner, as a table sorted by date (newest first), with a preview of the selected document underneath: Date saved | [folder] Folder | File name [document] | Pages | Format | [trash] - search box: filters the list on the file name and folder as you type - folder icon: opens the system file manager at that folder (the file is highlighted when the file manager supports it) - one click on a row: previews the document in the pane below (collapsible; drag the divider to resize it) - document icon (or double-click / Enter on a row): opens the document on the Document page (with Quick Edit) - the file name cell is editable: typing a new name renames the file on disk - trash icon: removes the entry from the list (the file is not touched) - Clear: All, or entries older than 5 / 10 / 20 / 30 / 60 / 90 days Icons are Phosphor Icons. The list is stored on this computer only (~/.local/share/linscanner/recent.json).

Constants: CLEAR_CHOICES, ICON_PX

Symbol Purpose
short_path(path) Folder part of a path with the home folder shown as ~
show_in_file_manager(path, window=None) Open the file manager at the file's folder, highlighting the file if possible
class RecentPage(BasePage) Saved documents, as a table with a preview pane
  .build_content(self) Title, Clear controls and the scrollable table
  .build_preview(self) Collapsible preview of the selected document, under the list
  .setting(self, key, default=None) A setting, tolerating a context without settings (used by lightweight tests)
  .on_paned_allocated(self, _paned, allocation) Give the preview the lower half the first time the page is shown
  .place_divider(self) Half and half while the preview is open; all list while it is collapsed
  .on_preview_toggled(self) Remember whether the preview pane is open, and fill it when it opens
  .on_selection_changed(self) A single click selects a row: show it in the preview pane
  .selected_path(self) Path of the selected row ('' if none)
  .show_preview(self) Render the first page of the selected document (only while the pane is open)
  .preview_dir(self) Where preview images are written (this session's folder)
  .preview_step(self, delta) Previous / next page of the previewed document
  .render_preview(self) Draw the current preview page and update the header
  ._preview_cache(self) One display cache for the preview pane
  .on_rename(self, _cell, path_str, new_text) Rename the file on disk (same folder, same extension) and in the list
  .set_message(self, text, error=False) Short feedback under the search row
  ._add_text_column(self, title, col, width, sort=None, expand=False, xalign=0.0, ellipsize=None) Fixed-width text column
  ._add_icon_column(self, col, action, tooltip) Narrow column of clickable icons
  ._column_at(self, x, y) (row path, column) under a point of the table, or (None, None)
  .on_shown(self) Refresh when opened (files may have been moved or deleted)
  .refresh(self, *_) Reload the table from the recent list (newest first)
  .on_click(self, _view, event) A click on an icon cell runs its action
  .on_motion(self, view, event) Hand pointer over the icon cells
  .on_tooltip(self, view, x, y, keyboard, tooltip) Tooltips for the icon cells
  .open_folder(self, path) Folder icon: the system file manager at the file's folder
  .open_document(self, path) Document icon: open the file on the Document page and start Quick Edit
  .forget(self, path) Trash icon: remove one entry (the file is not touched)
  .clear(self) Clear all entries, or those older than the chosen number of days (after confirming)

src/pages/page_scan.py

Scan Page Choose scanner, source, colour, quality and paper; scan with live progress. Pages go to the Document page as they arrive.

Constants: OPTION_BUTTON_WIDTH

Symbol Purpose
class ScanPage(BasePage) Scanner selection, scan options and the Scan button
  .build_content(self) Scanner card, options card, Scan/Cancel, progress and status
  ._blank_feature(self) The Blank-page removal module, or None if it was removed
  ._blank_enabled(self) True if blank pages are removed
  .on_blank_changed(self, key) Keep / Remove: switch the Blank-page removal module (Settings → Features follows)
  .sync_blank(self, *_) Follow the module's switch when it changes in Settings
  .on_settings_changed(self, key) Re-list devices if driver visibility changed; else refresh the summary
  .remember(self, key, value) Persist an option choice and refresh the summary
  .set_status(self, text, css='muted') Show a status message styled muted / ok / error / busy (errors and results are logged)
  .set_busy(self, busy, scanning=False) Enable or disable controls while working; Cancel only while scanning
  .current_device(self) The ScannerDevice selected in the combo, or None
  .show_device_issue(self, text=None) The red line under the scanner list: shown only when the scanner can't be used
  .looking(self, on) Spinner in place of the power mark while looking for the scanner
  .power(self, on, message=None, detail='') Green power mark when the scanner answered; red with a plain message when it didn't
  .startup(self) At start: reach the remembered scanner directly; otherwise do a full search
  .refresh_devices(self) Start a background device search (ignored while scanning)
  .devices_loaded(self, devices) Fill the scanner combo; reselect the last used scanner
  .devices_failed(self, message) A search or options error: plain words on screen, the details in the log
  .on_device_changed(self, combo) Remember the device and load its capabilities
  .caps_loaded(self, caps) Offer the scanner's Scan Types (from its sources) and mark it found
  .build_scan_types(self, sources) Segmented Scan Type control for this scanner (Front & Back only if it can scan duplex)
  .on_scan_type(self, key) User picked a Scan Type: remember it and use its source
  .choose_scan_type(self, key) Select the device source behind a Scan Type
  .sheet_mode(self) Current sheet mode: "all" (Multi-Page) / "one" (Single Page); feeder sources only
  .on_source_changed(self) Show the Sheets choice for feeder sources only, then refresh the summary
  .update_summary(self) Show exactly what will be sent to the scanner
  .build_request(self, dry_run=False) Build a ScanRequest from the controls (dry_run: no temp folder)
  .on_scan(self, _btn) Start scanning with the current options
  .on_page(self, _page) Count a finished page and notify the Document page
  .on_progress(self, pct) Update the progress bar for the page in progress
  .on_done(self, pages, cancelled) Report the result; open the Document page, or wait for the next sheet in one-sheet mode
  .after_scan(self, final) Tell feature modules a scan ended (final = the document is complete)
  .on_error(self, message) Show a scan error

src/pages/page_settings.py

Settings Page Theme, default save folder, Black & White style, network scanning (shown as Not Supported), features, driver visibility and diagnostics.

Symbol Purpose
class SettingsPage(BasePage) User preferences (saved to ~/.config/linscanner/settings.json)
  .build_content(self) Theme, scanning and driver setting cards
  ._tilde(path) A path with the home folder shown as ~
  .choose_save_folder(self) Folder dialog for the default save location
  .set_save_folder(self, path) Store the default save location and show it
  .save(self, key, value) Persist a setting and broadcast settings-changed
  .on_theme(self, combo) Apply and remember the selected theme
  .on_feature_toggled(self, check, feature) Enable or disable a feature module; pages update immediately
  .sync_feature_checks(self, *_) Follow switches made elsewhere (e.g. Scan page → Blank Pages)
  .open_log_folder(self) Open the log folder in the file manager
  .save_diagnostics(self) Save a zip with recent logs, system info, device info and feature states
  .on_show_all(self, btn) Toggle showing all drivers and the test scanner
  .draw_swatches(self, theme) Show colour dots for the theme's main colours
  ._draw_dot(area, cr, rgba) Cairo draw handler for one swatch

src/utils/util_autodetect.py

Auto-Detect paper size Finds the paper inside a scan made over the whole scan area, so the page can be cropped to the document (receipt, card, letter, ...). How: the background colour is taken from the scan's outer border (the feeder or lid around the paper). Pixels that clearly differ from it are paper; the rows and columns holding enough of them give the paper's box, which is kept (plus a small margin) and the rest cropped away. Limit: when the paper and the background are the same colour (white paper on a white backing) the edges can't be seen; content_box() then returns None and the caller keeps the page (or uses the chosen nominal size).

Constants: ANALYSIS_SIDE, DIFF, MARGIN_MM, LINE_SHARE, PAPER_SHARE

Symbol Purpose
content_box(img, dpi=300, threshold=DIFF, margin_mm=MARGIN_MM) (left, top, right, bottom) of the paper in img, or None if its edges can't be seen
nominal_box(img, size_mm, dpi) Box of a nominal paper size (w, h in mm), centred across and from the top
detect_crop(img, dpi, nominal_mm=None) (cropped image, how) for Auto-Detect: 'detected', 'nominal' or 'kept'

src/utils/util_display.py

Display images for the Document page A 600 dpi colour page is ~100 MB once decoded, so showing it straight from the scan is slow. Each page gets a small display copy ("proxy", at most PROXY_MAX_SIDE pixels, JPEG) made once, in the background as pages arrive. The large view and the thumbnails are drawn from it; only a deep zoom goes back to the original scan. Quick Edit layers and rotation are applied at display time, so the proxy never goes stale.

Constants: PROXY_MAX_SIDE, MAX_RENDER_PIXELS

Symbol Purpose
_fit_within(size, max_w, max_h) The size that fits max_w x max_h keeping the aspect ratio, capped by MAX_RENDER_PIXELS.
class DisplayCache() Proxy files for pages (thread-safe), kept in a cache folder
  .__init__(self, folder) folder: where proxies are written (the session's temp folder)
  .proxy(self, path) (proxy path, factor) for a page image, creating it if needed.
  ._proxy_locked(self, path) proxy() body, with the page's lock held
  .warm(self, paths) Create missing proxies in a background thread
  .original(self, path) The full-size page image (the most recent one is kept)
  .render(self, page, max_w, max_h, full_size=None) The page as an RGB image fitting max_w x max_h, rotated and with Quick Edit layers.
  .size(self, page) Full-resolution (w, h) of a page after rotation
page_key(page) Identity of what a page looks like (image and its version, rotation, Quick Edit layers)

src/utils/util_fonts.py

Font helpers for Quick Edit - The 20 basic fonts offered for text, resolved to files through fontconfig (fc-match). Only families that are really installed are offered. - Signature fonts: the bundled ones (resources/fonts/signature, SIL Open Font License, listed in fonts.json with their credits) plus fonts the user adds (~/.local/share/linscanner/fonts; kept on this computer, never bundled). - render_text(): the one text renderer used by the editor and by Save, so what you see is what gets saved.

Constants: FONT_EXTENSIONS, BASIC_FONTS

Symbol Purpose
_match(family) (matched family, file) from fc-match, or (None, None)
font_file(family) Font file for a family (fontconfig picks a fallback if it's missing)
available_fonts() The basic fonts that are actually installed (no silent substitutes)
user_fonts_dir() Fonts the user added (~/.local/share/linscanner/fonts)
_family_of(path) Font family name read from the font file (None if it can't be read)
bundled_signature_fonts() Bundled signature fonts with credits: [{family, path, designer, copyright, license, ...}]
user_signature_fonts() Fonts the user added: [{family, path, bundled: False}]
signature_fonts() Bundled + user signature fonts, one entry per family (bundled first)
import_fonts(paths) Copy .ttf/.otf files (or the fonts inside .zip files) to the user font folder.
remove_user_font(path) Delete a font the user added
signature_font_file(family) Font file of a signature font family, or None
text_fonts() Every family offered for text: the basic fonts, then the signature fonts
resolve_font(family) Font file for any family offered by LinScanner (signature fonts first)
load_font(family, px) Pillow font object for a family at a pixel size (cached)
text_metrics(text, family, px) (advance width, line height) in pixels: the text's box from its anchor
render_text(text, family, px, color='#000000') Text as a transparent RGBA image; returns (image, dx, dy): the image's
layout_runs(runs, px_per_pt) Line layout of styled runs: (width, line height, baseline, [x of each character boundary])
render_runs(runs, px_per_pt) Styled runs as one transparent RGBA image on a shared baseline; returns (image, dx, dy).

src/utils/util_guides.py

Alignment guides for Quick Edit While an item is placed or dragged, its edges are compared with the items already on the page. When one is within a few pixels of a useful line, the item snaps to it and a guide is drawn. This is a soft snap: move a little further and it lets go, and holding Alt switches snapping off. Lines considered (all in page fractions, 0..1): - align: the left / centre / right edges and top / bottom of other items - center: the page's vertical centre line (item centred on the page) - mirror: the mirror image of another item across the page centre (symmetry) - spacing: equal vertical spacing: the next row after two rows, or the gap a text line would leave below another text item

Symbol Purpose
class Box() An item's rectangle in page fractions
  .right(self) Right edge
  .bottom(self) Bottom edge
  .center(self) Horizontal centre
class Guide() A guide line to draw: axis "v" (x = value) or "h" (y = value)
x_candidates(box, others) [(offset, guide lines)] that would line box up horizontally
y_candidates(box, others) [(offset, guide lines)] that would line box up vertically or space it evenly
_best(candidates, threshold) The smallest offset within threshold, with its guides (0, [] if none)
snap(box, others, threshold_x, threshold_y) (new_left, new_top, guides) for box, snapped to the nearest guide on each axis.

src/utils/util_icons.py

Icons Phosphor Icons v2.0.8 (Helena Zhang and Tobias Fried, MIT): resources/icons/phosphor/regular/.svg (other weights: /-.svg). The SVGs draw with currentColor, which is replaced by the theme's text colour (or a given colour) before rendering, so icons suit light and dark themes. Rendered pixbufs are cached per (name, size, colour, weight). To add an icon, copy its SVG from ~/projects/assets/Icons/phosphoricons (see its INDEX.txt for names and search tags) into resources/icons/phosphor/regular/.

Constants: DEFAULT_COLOR

Symbol Purpose
set_icon_color(color) Colour for icons without an explicit colour (set from the active theme)
icon_path(name, style='regular') SVG path of a bundled icon in a Phosphor weight (regular, bold, fill, ...)
icon_pixbuf(name, size=20, color=None, style='regular') The icon as a pixbuf of size x size pixels, drawn in color (theme text colour by default)
icon_image(name, size=20, color=None, style='regular') A Gtk.Image of the icon (a generic icon if the file is missing)
icon_button(name, tooltip, on_click=None, size=18, toggle=False, color=None) A compact, square button showing only an icon (the tooltip names the action)
icon_label_button(name, label, tooltip=None, on_click=None, size=16) A button with an icon followed by a short label

src/utils/util_imaging.py

Imaging helpers shared by the core and feature modules (Pillow + numpy). Kept in the core so features never depend on each other.

Symbol Purpose
small_gray(img, width=600) Grayscale copy scaled to about width pixels wide (fast analysis)
ink_ratio(img, margin=0.05) Share of pixels that differ from the paper, ignoring a margin (edges, shadows).
is_blank(img, threshold=0.002) True if the page has (almost) no ink
overlay_pixels(page, size) Overlay position helper: fractions of the page -> pixels for an image of size
composite_at(base, layer, x, y) Alpha-composite an RGBA layer onto base at (x, y); parts outside the page are cut off
crop_box(size, left, top, right, bottom, minimum=8) Pixel box (l, t, r, b) for fractions of an image, or None when it would be too small
flatten(page, img=None) Page image with rotation and Quick Edit overlays applied

src/utils/util_logging.py

Logging One log file per day in /.local/state/linscanner/logs/ (or $XDG_STATE_HOME), kept for 14 days and capped in size. Every line is redacted: scanner serial numbers become "…" and the home folder becomes "". Logging problems never stop the app: if the folder can't be written, logging quietly goes nowhere. Use: from utils.util_logging import get_logger; log = get_logger("scan")

Constants: KEEP_DAYS, MAX_BYTES, ROOT

Symbol Purpose
log_dir() Folder holding the log files
redact_text(text) Remove serial numbers and the home folder path from a log line
class RedactingFormatter(logging.Formatter) Formatter that redacts every formatted line (incl. tracebacks)
  .format(self, record) Format, then redact
_prune(folder) Delete log files older than KEEP_DAYS
setup_logging(debug=False) Configure the 'linscanner' logger (idempotent); returns the log file path or None
get_logger(name) Logger for one part of the app, e.g. get_logger('scan') -> 'linscanner.scan'
timed(logger, what, level=logging.DEBUG) Log how long a block took: 'what took 1.23 s'
install_excepthook() Log uncaught exceptions (main thread and worker threads) before the default handling
system_info() Multi-line description of the environment (versions, desktop, tools)
write_diagnostics(path, extra_sections=None, days=3) Zip the recent logs + system info (+ extra sections) for sending; returns path

src/utils/util_paths.py

Path helpers Locations of the app root and bundled resources, independent of the cwd.

Constants: APP_ROOT

Symbol Purpose
resource(*parts) Absolute path of a file under resources/
read_version() Version string from the VERSION file
data_dir(*parts) Folder under ~/.local/share/linscanner (honours XDG_DATA_HOME); created on demand

src/utils/util_signatures.py

Signature library Up to MAX_SIGNATURES saved signatures (transparent PNGs) in ~/.local/share/linscanner/signatures/, kept between sessions. A signature is either an imported PNG or a name typed in a signature font.

Constants: MAX_SIGNATURES, TYPED_SIGNATURE_PX

Symbol Purpose
class LibraryFull(Exception) The library already holds MAX_SIGNATURES signatures
signatures_dir() Signature library folder (~/.local/share/linscanner/signatures)
library() Saved signature PNGs, oldest first (so slots keep their place)
is_full() True if no more signatures can be saved
has_transparency(path) True if a PNG has any transparent pixels
_unique_path(folder, name) folder/name.png, or name-2.png, name-3.png, ... if taken
_trim(img) Crop an RGBA image to its visible pixels (plus a small margin)
prepare_png(path, clear_white=False) An imported PNG as RGBA (optionally with near-white made transparent), trimmed
typed_signature(text, family, color='#1a1a1a', px=TYPED_SIGNATURE_PX) A name rendered in a signature font, as a trimmed transparent RGBA image
save_signature(img, name, folder=None) Save an RGBA image to the library; raises LibraryFull when 4 are saved. Returns the path.
remove_signature(path) Delete a saved signature

src/utils/util_textruns.py

Styled text runs for Quick Edit A text item holds "runs": [{"text", "font", "size_pt", "color"}, ...], drawn left to right on one baseline, so a few highlighted words can have their own font, size or colour. Older items with a single text / font / size_pt / color are read as one run. All positions are character offsets into the item's plain text.

Constants: STYLE_KEYS, DEFAULT_STYLE

Symbol Purpose
runs_of(item) The item's runs (converting an older single-style item)
plain(runs) The runs' text without styles
style_of(run) A run's style
style_at(runs, pos) The style that text typed at pos continues (the character before pos; the first run at 0)
tidy(runs) Merge neighbours with the same style and drop empty runs (one run is always kept)
split_at(runs, pos) Runs split so that a run boundary falls at pos
insert(runs, pos, text, style=None) Runs with text inserted at pos (in style, or the style at pos)
delete(runs, start, end) Runs with the characters start..end removed
restyle(runs, start, end, **style) Runs with characters start..end given the style (font / size_pt / color)
word_at(text, pos) (start, end) of the word around pos
set_runs(item, runs) Store runs on an item, keeping the older single-style fields in step (first run)

src/features/__init__.py

Feature modules Optional features live here as feature_.py files. The core never imports them directly: it calls the FeatureRegistry at a few hook points, and every hook call is isolated, so a feature that is disabled, deleted or broken can't affect scanning, preview or export. Hooks a feature may implement (all optional): process_page(image, page) -> image | None after each scanned page (None = drop page) split_documents(pages) -> [[page, ...], ...] before export: one list per output file export_pdf(pages, path) -> bool write a PDF itself (True = done) postprocess_pdf(path) after a PDF was written after_scan(ctx, pages, final) after a scan job (final = document complete) extend_scan_page(page), extend_preview(page) add widgets to those pages settings_widget(ctx) -> Gtk.Widget | None per-feature settings (Settings page)

Symbol Purpose
class BaseFeature() Base class for features; override the hooks you need
  .__init__(self, settings) settings: the SettingsManager (feature options live under feature_settings[id])
  .option(self, key, default) This feature's stored option (or default)
  .set_option(self, key, value) Store one of this feature's options
class FeatureRegistry() Discovers feature_*.py modules and dispatches hooks to the enabled ones
  .__init__(self, settings, folder=None) Load every feature module in folder (default: this package)
  ._load(name, path) Import a feature module from exactly this file (not whatever shares its name)
  .is_enabled(self, feature) Enabled per settings, else the feature's default
  .set_enabled(self, feature_id, enabled) Turn a feature on or off (persisted)
  .get(self, feature_id) Feature by id (loaded, enabled or not), or None
  .enabled(self, hook=None) Enabled features, optionally only those implementing a hook
  ._call(self, feature, hook, *args) Call one hook, isolating failures; returns (ok, result)
  .process_page(self, image, page) Run page processors in order; None means the page should be dropped
  .split_documents(self, pages) Output documents (lists of pages); default: one document
  .export_pdf(self, pages, path) Let a feature write the PDF (e.g. OCR); True if one did
  .postprocess_pdf(self, path) Run PDF post-processors (e.g. PDF/A, compression)
  .after_scan(self, ctx, pages, final) Notify features that a scan job ended
  .extend(self, hook, page) Let features add widgets to a page (hook: extend_scan_page / extend_preview).

src/features/feature_autocrop.py

Auto-crop Removes the extra length a sheet feeder scans past the end of the sheet: a uniform band at the bottom whose tone differs from the paper. Page margins are never cut. If the overrun looks identical to the paper, nothing is trimmed (use the Paper size setting instead).

Symbol Purpose
trailing_band(a) Row (in the small image) where the uniform overrun band starts, or None
class Feature(BaseFeature) Trim the feeder overrun at the end of the sheet
  .process_page(self, image, page) Crop off a detected overrun band (plus a small margin kept)

src/features/feature_autorotate.py

Auto-rotate Detects page orientation with Tesseract OSD and turns upside-down or sideways pages upright (e.g. sheets loaded the wrong way round).

Symbol Purpose
detect_rotation(image) Clockwise degrees Tesseract says the page needs (0/90/180/270), or 0 if unsure
class Feature(BaseFeature) Turn pages upright using orientation detection
  .process_page(self, image, page) Rotate the page upright if Tesseract is confident

src/features/feature_autosave.py

Auto-save Saves each finished scan automatically as a PDF in a chosen folder, named from a template: {date} {time} {n} {pages} {mode}. Example: {date}-{time}-scan.pdf

Constants: DEFAULT_TEMPLATE

Symbol Purpose
render_name(template, pages, when=None, n=1) File name (without folder) from a template
class Feature(BaseFeature) Save finished scans automatically
  .after_scan(self, ctx, pages, final) Save the session's pages when a document is complete
  .settings_widget(self, ctx) Folder and template

src/features/feature_batch_split.py

Batch splitting Put a blank sheet between documents in the feeder: each blank page starts a new document, and Save As writes one file per document (name-001.pdf, ...). Blank pages are detected even when blank-page removal is off.

Symbol Purpose
class Feature(BaseFeature) Split a scanned stack into documents at blank separator sheets
  .process_page(self, image, page) Mark blank pages as separators (kept until export)
  .split_documents(self, pages) Documents between separator pages (empty documents are skipped)

src/features/feature_blank_removal.py

Blank-page removal Drops pages with (almost) no ink, e.g. the empty backs of duplex scans. If batch splitting is on, blank pages are kept as document separators instead.

Symbol Purpose
class Feature(BaseFeature) Remove blank pages
  .process_page(self, image, page) None (drop) for blank pages; mark as separator when batch splitting is on
  .settings_widget(self, ctx) Sensitivity slider

src/features/feature_deskew.py

Deskew Straightens pages fed at a slight angle (up to ±5°) using a projection profile: text lines are sharpest when they're horizontal.

Constants: MAX_ANGLE, STEP

Symbol Purpose
find_skew(image) Angle in degrees (counter-clockwise positive) that straightens the text
class Feature(BaseFeature) Straighten slightly rotated pages
  .process_page(self, image, page) Rotate the page by the detected skew (skipped for near-empty pages)

src/features/feature_enhance.py

Image enhancement Brightness, contrast, sharpening, despeckle and background whitening applied to each scanned page (colour and grayscale; pure black-and-white is left alone).

Constants: DEFAULTS

Symbol Purpose
class Feature(BaseFeature) Improve legibility of scanned pages
  .process_page(self, image, page) Apply the configured adjustments
  .settings_widget(self, ctx) Sliders and switches for the adjustments

src/features/feature_import_images.py

Import images Adds image files (PNG, JPEG, TIFF, multi-page TIFF) as pages. This is also the last-resort acquisition method: if no driver works, scan to a USB stick or network folder on the scanner itself and import the files here.

Constants: EXTENSIONS

Symbol Purpose
import_files(ctx, paths) Append image files to the session as pages; returns the number of pages added
class Feature(BaseFeature) Add image files as pages
  .extend_preview(self, page) Add an 'Add Image' button to the Document toolbar's pages group
  .choose(self, page) File dialog, then import

src/features/feature_ocr.py

Searchable PDF (OCR) Saves PDFs with an invisible text layer made by Tesseract (English), so the text can be searched, selected and copied.

Constants: LANGUAGE

Symbol Purpose
class Feature(BaseFeature) Make saved PDFs searchable
  .export_pdf(self, pages, path) Write a searchable PDF with Tesseract; False if Tesseract isn't available

src/features/feature_pdf_options.py

PDF options Post-processes saved PDFs with Ghostscript: PDF/A-2b for long-term archiving and/or smaller files (downsampled, recompressed images).

Constants: SIZES

Symbol Purpose
class Feature(BaseFeature) PDF/A archiving and file-size options
  .postprocess_pdf(self, path) Rewrite the PDF with Ghostscript per the chosen options
  .settings_widget(self, ctx) PDF/A switch and size choice

src/features/feature_profiles.py

Scan profiles One-click presets on the Scan page (colour, quality, paper, sheets), with built-in profiles plus your own ("Save current as profile").

Constants: BUILT_IN

Symbol Purpose
class Feature(BaseFeature) Presets for the Scan page
  .all_profiles(self) Built-in profiles plus the user's own
  .apply(self, scan_page, name) Set the Scan page controls from a profile
  .save_current(self, scan_page, name) Store the Scan page's current choices as a user profile
  .extend_scan_page(self, page) Profile picker + 'Save as profile' at the top of the Options section

src/features/feature_quick_edit.py

Quick Edit Add text and signatures to scanned pages, like mainstream PDF editors. - Add Text: the pointer becomes a text cursor; click anywhere on the page and type. Alignment guides snap softly to earlier text (same edges, centre, equal spacing, symmetry) without locking; hold Alt to place freely. - Apply Signature places the chosen signature (click where it goes, drag the corner square to resize). The edit icon next to it opens the signature chooser: up to 4 saved signatures (kept between sessions), delete, and Create Signature (type your name in a signature font, or upload a PNG). Quick Edit applies signatures; creating them happens in that dialog. - Pointer: a hand on an item's frame (drag to move), a text cursor inside text (click to edit), a diagonal arrow on the resize corner. - Items can be moved (with guides), resized, edited, deleted and applied to every page. Edits are overlays stored on the page and flattened only when saving (non-destructive). Overlay model (positions/sizes are fractions of the page): {"type": "text", "text": str, "font": family, "size_pt": float, "color": "#rrggbb", "x": f, "y": f} {"type": "image", "path": signature.png, "x": f, "y": f, "w": f (width as page fraction)}

Constants: HANDLE, SNAP_PX, DEFAULT_SIGNATURE_WIDTH, SIGNATURE_INK

Symbol Purpose
import_signature(path, clear_white=False) Add a PNG to the signature library (optionally making near-white transparent); returns the new path
_hex(rgba) Gdk.RGBA -> '#rrggbb'
on_paper(img, pad=10) A transparent signature on a white "paper" tile, so dark ink shows on the dark theme
_pixbuf(img) Pillow image -> GdkPixbuf (RGBA kept)
class Feature(BaseFeature) Text and signatures on scanned pages
  .extend_preview(self, page) Add Text and Signature buttons to the Document toolbar's edit group
  .open_editor(self, preview_page, start=None) Open the editor for the selected page (start: "text" or "signature"); store overlays on Apply
class QuickEditor() Modal editor window: canvas + tools. run() returns True if changes were applied.
  .__init__(self, ctx, pages, index) Build the dialog for pages[index] (overlays are edited on a copy)
  ._stop_blink(self) Stop the caret timer when the dialog closes
  ._mask(*names) Combine Gdk event mask names
  ._tools(self) Right-hand panel: tools, text style, Apply Signature, item actions (icons: Phosphor)
  ._heading(self, text) Section heading label
  .show_hint(self, text=None) Help text for the current tool (or a message)
  .current_signature(self) Path of the chosen signature (remembered between sessions), or None
  .set_current_signature(self, path) Remember the chosen signature
  .show_current_signature(self) Small preview of the chosen signature under Apply Signature
  .apply_signature(self) Apply Signature: place the chosen signature with the next click (or choose one first)
  .open_signature_chooser(self) Edit icon: choose a saved signature, remove one, or create a new one
  .signature_chosen(self, path) A signature was picked or created: make it current and place it with the next click
  .signature_removed(self, path) A saved signature was deleted: keep copies already placed working this session
  ._on_tool_toggled(self, button) Tool buttons: Add Text / Select
  .set_mode(self, mode) Switch tool: select
  ._set_cursor(self) Text cursor for Add Text, crosshair while placing, default otherwise
  .on_realize(self, widget) Connect the input method to the canvas window
  .place_signature_on_click(self, path) Arm place mode: the next click on the page places this signature
  .current_style(self) (font, size_pt, colour) from the panel
  .selection(self) (start, end) of the highlighted characters in the text being edited, or None
  .style_changed(self) Font / size / colour changed in the panel.
  ._load_style(self, item, pos=None) Show the style at a position of a text item in the panel (without restyling it)
  .new_text_item(self, x, y, text='') Create a text item at page fractions (x, y) with the panel's style
  .add_text(self) Add the text from text_entry near the top-left (scripted use)
  .start_editing(self, item, caret=None) Type into a text item on the canvas (caret: character position; default the end)
  .finish_editing(self) Stop typing; an empty text item is removed
  .select_range(self, start, end) Highlight characters start..end of the text being edited (the caret goes to end)
  ._replace_selection(self, text='') Delete the highlighted characters (if any) and insert text at the caret
  .type_text(self, text) Insert typed text at the caret (replacing highlighted words)
  .on_commit(self, _im, text) Characters from the input method
  .backspace(self, forward=False) Backspace (or Delete): remove the highlighted words, or one character
  .move_caret(self, pos, extend=False) Move the caret (extend: grow the highlight, as with Shift)
  .place_signature(self, path, x=0.55, y=0.8) Place a signature with its top-left at page fractions (x, y)
  .delete_selected(self) Remove the selected item
  .apply_to_all(self) Copy the selected item to the same position on every other page
  .move_item(self, item, fx, fy) Move an item to page fractions (clamped to the page)
  ._layout(self) (scale, offset_x, offset_y, page_w, page_h) mapping page pixels to the canvas
  ._base_image(self) The page with rotation applied (no overlays), cached
  ._dpi(self) Page resolution (text sizes are in points)
  ._signature_image(self, path) Signature as a Pillow RGBA image (cached)
  ._text_layout(self, item) (width, line height, baseline, character x positions) of a text item, in page pixels
  .item_box(self, item) Item rectangle in page fractions (text: its line box; signature: its image)
  ._bbox(self, item) Item rectangle on the canvas: (x, y, w, h)
  .char_x(self, item, pos) Canvas x of the caret position pos in a text item
  .pos_at(self, item, ex) The caret position nearest to canvas x ex in a text item
  .to_page(self, ex, ey) Canvas pixels -> page fractions
  .snapped(self, box, moving=None, free=False) (left, top) for box snapped to the alignment guides; sets self.guides
  ._ghost_box(self, fx, fy) Box of what the next click would create at (fx, fy)
  ._page_pixbuf(self, w, h) The page scaled to (w, h), cached per canvas size (drawing stays fast while dragging)
  ._text_pixbuf(self, item, scale) (pixbuf, dx, dy) for a text item's styled runs at canvas scale, cached
  ._signature_pixbuf(self, path, w, h) Signature scaled to (w, h) on the canvas, cached
  .on_draw(self, widget, cr) Draw the page, the overlays, the text highlight and caret, guides and the selection frame
  ._toggle_caret(self) Blink the caret while typing
  ._hit(self, ex, ey) (item, zone) under the pointer, topmost first; zone: resize (signatures only)
  .cursor_for(item, zone, mode, dragging=False) Pointer name for what a click would do (hand = move, text = edit, arrows = resize)
  ._pointer(self, name) Set the canvas pointer by name
  ._free(self, event) Alt held: place / move without snapping
  .on_press(self, widget, event) Place text or a signature; click inside text to put the caret there (drag to highlight);
  .on_motion(self, widget, event) Highlight while dragging in text; move / resize the dragged item; ghost and pointer
  .on_release(self, widget, event) End a drag or a highlight
  .on_leave(self, *_) Pointer left the canvas: hide the ghost and guides
  .on_key(self, widget, event) Typing and text keys while editing; otherwise Esc, Delete and arrow-key nudging
  .new_line(self) Enter while typing: finish this line and start the next one below it, same left edge and style
  .commit(self) Write the edited overlays back to the page(s)
  .run(self) Show modally; True if the user applied the changes
class SignatureChooser() Popover card from the edit icon: the saved signatures (pick one, or delete it) and Create Signature
  .__init__(self, editor) Build the card next to the edit icon
  .rebuild(self) Tiles for the saved signatures, empty slots, and Create Signature
  .popup(self) Show the card
  .choose(self, path) Pick a signature: it becomes current and is placed with the next click
  .delete(self, path) Delete a saved signature (after confirming)
  .create(self) Create Signature: the creation dialog; a new signature becomes current
class SignatureCreator() Create Signature dialog: type your name in a signature font, or upload a PNG; saves to a slot
  .__init__(self, parent, Gtk, Gdk, GLib) Two ways in (Type it / Upload a PNG), Save Signature
  ._type_page(self) Name, ink colour and the font list with live previews
  ._schedule(self) Re-render previews shortly after typing stops
  .text(self) The name to render
  .rebuild(self) One row per signature font showing the name in that font (on white paper)
  .add_fonts(self) Import font files (or zips of fonts) into the user font folder
  ._upload_page(self) Pick a PNG; optionally make its white background transparent; preview
  .png_changed(self, auto=False) Preview the chosen PNG as it will be saved
  .build(self) (image, name) from the visible tab, or (None, reason)
  .run(self) Show modally; the saved signature's path, or None