document_editor.py Chromium-Based WYSIWYG Rich Text Editor for PySide6
📄 4,317 lines | 📦 PySideAbdhUI Package | ⚙ Python 3.10+ / PySide6
document_editor.py implements a fully-featured WYSIWYG (What You See Is What You Get) rich text editor widget
for desktop applications built with the PySide6 (Qt for Python) framework. The editor is built on top of
QWebEngineView, which embeds a Chromium rendering engine inside a native Qt widget. This hybrid architecture
leverages the power of modern web technologies — HTML, CSS, and JavaScript — for the editing surface, while the
surrounding Qt infrastructure handles desktop integration concerns such as native file dialogs, context menus,
colour pickers, and window management.
The editor presents itself as a multi-page document layout, where each page is a <div class="page">
element with fixed CSS dimensions that correspond to a real paper size (A4, Letter, B5, or a custom "Edu-Item" format).
Content overflow is automatically managed by a JavaScript pagination engine that pushes overflowing blocks onto
subsequent pages and pulls underflowing content back when space becomes available. This approach gives users a
real-time preview of how their document will look when printed or exported to PDF.
Key capabilities of the editor include: KaTeX-based mathematical formula rendering with inline and display modes, image insertion with drag-and-drop repositioning and 8-directional resize handles, table editing with column resizing and row/column insertion, PDF import via Mozilla's pdf.js library, PDF export using Chromium's built-in renderer, and DOCX/ODT import/export via pypandoc. The editor also supports zoom, page navigation, margin control, text formatting (bold, italic, underline, strikethrough, font family, font size, text and background colours), paragraph alignment, and bidirectional text support.
Design Philosophy The editor follows a "web-inside-Qt" design where all visual editing happens in JavaScript inside Chromium, while Qt serves purely as the desktop shell providing native dialogs, menus, and file system access. Communication between the Python and JavaScript layers is achieved through QWebEnginePage.runJavaScript() calls (Python → JS) and the document.title bridge (JS → Python).
Architecture
The system follows a layered architecture where the Python RichTextEditor class acts as the
controller, the Chromium QWebEngineView as the rendering engine, and an embedded HTML document
as the editing surface. The following diagram illustrates the high-level component relationships and data
flow between the layers.
Python → JavaScript Communication
The primary channel for Python-to-JavaScript communication is the
QWebEnginePage.runJavaScript() method. This is an asynchronous API — Python submits
a JavaScript string for execution, and an optional callback receives the result. The RichTextEditor
class uses this mechanism extensively: every formatting command, content manipulation, page size change,
and zoom operation is implemented as a JavaScript fragment dispatched through runJavaScript().
For operations that require a synchronous result (such as reading the editor's HTML content before a
Pandoc export), the class employs a QEventLoop to block the calling thread until the
asynchronous JavaScript callback fires. This pattern is visible in methods like
getHtmlContentSync() and getPlainTextSync().
JavaScript → Python Communication
The reverse direction uses a creative "document title" bridge. When the user edits content in the browser,
the JavaScript input event listener sets document.title to a string starting
with "__edit__" followed by a timestamp. On the Python side, the
page().titleChanged signal is connected to _on_title_changed(), which
interprets this prefix as a notification that the document has been modified and sets the
_is_dirty flag accordingly.
Dependencies
Standard Library
| Module | Purpose |
|---|---|
base64 | Encode binary assets (images, JS bundles, PDF bytes) into base64 for HTML data-URI embedding |
html | Escape LaTeX formula text for safe storage in data-latex attributes on math placeholder elements |
json | Safely serialize Python strings into JavaScript string literals for runJavaScript() calls |
os | Environment variable management, filesystem operations during import/export |
re | Regular expressions for parsing HTML bodies, converting MathJax script tags, extracting style blocks |
tempfile | Create short-lived temporary HTML files consumed by pypandoc during DOCX conversion |
PySide6 (Qt for Python)
| Module | Classes | Role |
|---|---|---|
PySide6.QtCore | QEventLoop, QMarginsF, Qt, QTimer | Event loop blocking, print margins, enums, deferred execution |
PySide6.QtGui | QColor, QPageLayout, QPageSize | Colour dialogs, PDF page configuration |
PySide6.QtWidgets | QDoubleSpinBox, QMenu, QFileDialog, QMessageBox, QSpinBox, QDialog, QColorDialog, QInputDialog, QFormLayout, QDialogButtonBox, QSizePolicy | Desktop dialogs, context menus, form layouts, size policies |
PySide6.QtWebEngineWidgets | QWebEngineView | Chromium web view widget (base class of RichTextEditor) |
PySide6.QtWebEngineCore | QWebEnginePage | Browser actions (Cut/Copy/Paste), console message hook |
Package-Internal
| Import | Purpose |
|---|---|
PySideAbdhUI.utils.get_resource_path | Resolve paths to bundled resources (KaTeX, pdf.js, pdf-lib) regardless of whether the application runs from source or an installed wheel |
Optional Dependency pypandoc is required for DOCX and ODT import/export. If it is not installed, the editor still loads and functions normally, but DOCX-related operations will display a missing-dependency warning dialog instead of performing the conversion. The HAS_PANDOC flag controls this graceful degradation.
Class: DebugPage
DebugPage is a lightweight subclass of QWebEnginePage that overrides the
javaScriptConsoleMessage() virtual method. By default, Chromium silently discards JavaScript
console output when running inside a QWebEngineView. This class intercepts every message
written to the browser console (via console.log(), console.warn(),
console.error(), etc.) and mirrors it to the Python standard output, prefixed with the
source line number. This makes debugging of the embedded JavaScript significantly easier during
development and testing.
def javaScriptConsoleMessage(self, level, message, lineNumber, sourceID)
Override
Intercept a JavaScript console message. Prints "JS[lineNumber] message" to Python's stdout, then forwards the message to the base class implementation for normal Qt processing.
Class: RichTextEditor
RichTextEditor is the main public widget and the heart of the module. It inherits from
QWebEngineView and provides a complete WYSIWYG editing experience inside a Chromium-based
web view. The class exposes a rich public API for text formatting, content management, page layout
control, file import/export, and more, while delegating all visual editing operations to JavaScript
running inside the embedded HTML document.
Constructor & Initialization
def __init__(self, default_size: str = 'A4', parent=None)PublicConstructs the editor widget, generates the initial HTML template, and wires up all Qt/JS communication bridges.
Parameters:
default_size – Initial page size name. Must be one of: "A4", "Letter", "B5", "Edu-Item". Defaults to "A4".parent – Optional parent widget for Qt ownership semantics.The constructor performs the following initialization sequence:
- Calls
super().__init__(parent)to initialise the underlyingQWebEngineView. - Replaces the default
QWebEnginePagewith aDebugPageinstance for JS console mirroring. - Sets the size policy to
Expandingin both directions so the editor fills available layout space. - Initialises the
_page_marginsdictionary with 10 mm on all sides. - Disables Chromium's default context menu and connects
customContextMenuRequestedto the custom menu handler. - Initialises lazy-loading caches for pdf.js, pdf.worker, and pdf-lib base64 data.
- Generates and loads the initial HTML document via
_build_initial_html(). - Connects
loadFinishedfor deferred content injection. - Initialises the
_is_dirtyflag and connectstitleChangedfor edit notifications. - Overrides
javaScriptConsoleMessagewith a lambda that prints detailed JS debug output.
PAGE_SIZES Class Constant
| Name | Width (mm) | Height (mm) | Description |
|---|---|---|---|
"A4" | 210 | 297 | ISO A4 — default paper size |
"Letter" | 215.9 | 279.4 | US Letter (8.5 in × 11 in) |
"B5" | 176 | 250 | ISO B5 — compact book/journal format |
"Edu-Item" | 210 | 50 | Custom short-page format for educational item cards |
Dirty Tracking
The editor tracks whether the document contains unsaved modifications through the _is_dirty boolean flag. When the user types, deletes, or otherwise modifies content, the JavaScript input event listener sets document.title to "__edit__<timestamp>", which triggers the _on_title_changed() slot on the Python side and sets _is_dirty = True. The flag can also be set manually via _mark_dirty(), and is cleared when the document is saved via setClean(). The current state is queryable through isModified().
def isModified(self) -> boolPublicReturns True if the document has unsaved changes, False otherwise.
def setClean(self)PublicMarks the document as saved by clearing the dirty flag.
def _mark_dirty(self)PrivateExplicitly marks the document as modified.
def _on_title_changed(self, title: str)PrivateReceives edit notifications from JavaScript through the document.title bridge. If the title starts with "__edit__", sets _is_dirty = True.
Print Preparation
Before generating a PDF or initiating a print operation, the editor must ensure that its internal page structure is consistent and that no blank trailing pages will appear in the output. The _preparePrintBreaks() method executes JavaScript that removes empty trailing pages and sets a temporary data-print-ready attribute on every remaining page element. After the export completes, _cleanupPrintBreaks() removes these markers and triggers a full page-consistency check.
def _preparePrintBreaks(self)PrivateRemoves empty trailing pages and marks all pages as print-ready before PDF export.
def _cleanupPrintBreaks(self)PrivateRemoves data-print-ready attributes from all pages and re-runs checkAllPages().
Image Export (Page-by-Page PNG Capture)
The extractAsImages() method provides a mechanism to capture each logical editor page as a separate PNG image file. The capture process is multi-step and asynchronous, using a callback chain that processes pages one at a time:
For each page, the widget is temporarily resized to match the page dimensions exactly at zoom 1.0, the viewport is scrolled to the correct position, and after a 200 ms rendering delay, self.grab() captures the widget contents as a QPixmap. After all pages are captured, the original widget size, zoom, and size constraints are restored, and the user is prompted to select an output directory. Each image is saved as page_1.png, page_2.png, etc.
def extractAsImages(self)PublicCapture each .page element as a separate PNG image using self.grab().
Context Menus
The editor disables Chromium's default right-click context menu and replaces it with custom Qt menus that are context-aware. When the user right-clicks inside a table cell (<td> or <th>), a table-specific menu appears with options to insert rows and columns. For all other locations, the standard editing menu is shown.
Default Menu Actions
| Category | Actions |
|---|---|
| Clipboard | Cut, Copy, Paste, Select All |
| Alignment | Align Center, Align Justified, Align Left, Align Right |
| Direction | Change Text Direction LTR, Change Text Direction RTL |
| Text Style | Toggle Bold, Toggle Italic, Toggle Underline, Toggle Strikethrough |
Table Menu Actions
When right-clicking inside a table cell, the menu adds the following actions above a "Text Format" submenu containing all the default actions:
- Insert Row Above — adds a new empty row above the current row
- Insert Row Below — adds a new empty row below the current row
- Insert Column Left — adds a new empty column to the left of the current column
- Insert Column Right — adds a new empty column to the right of the current column
KaTeX Resource Loader
The _load_katex_resource() method reads KaTeX library files (CSS and JavaScript) from the application's packaged resources and converts them into base64 data-URI strings that can be embedded directly in the <link> and <script> tags of the editor HTML. This approach avoids filesystem path issues and makes the editor self-contained regardless of the installation directory.
If a KaTeX resource file is not found, the method returns an empty string, and the corresponding tag is replaced with an HTML comment in _build_initial_html(). This graceful degradation ensures the editor still loads and functions even when KaTeX is unavailable — math formulas simply won't be rendered.
def _load_katex_resource(self, name: str, ext: str = 'css') -> strPrivateRead a KaTeX file from the package resources and return a base64 data-URI string. Returns '' if the file is not found.
name – Resource filename without extension (e.g., "katex.min", "auto-render.min")ext – File extension: "css" or "js". Determines the MIME type in the data-URI.PDF Library Lazy Loading
The PDF-related JavaScript libraries (pdf.js, pdf.worker, and pdf-lib) are not embedded in the initial HTML document to keep page load times fast and avoid errors when the files are missing. Instead, they are loaded on-demand when the user first opens a PDF file. Once loaded, the base64 data is cached in instance variables to prevent repeated disk reads.
Caching Strategy All three loaders follow the same pattern: check the cache first, attempt to load and encode the file on a cache miss, store the result (or an empty string on failure) in the cache, and return the cached value. This ensures that each file is read at most once per editor instance.
| Method | Resource | Cache Variable |
|---|---|---|
_load_pdfjs_b64() | pdf.min.js (Mozilla pdf.js core) | _pdfjs_js_b64 |
_load_pdfjs_worker_b64() | pdf.worker.min.js (pdf.js worker) | _pdfjs_worker_b64 |
_load_pdflib_b64() | pdf-lib.min.js (PDF manipulation) | _pdflib_js_b64 |
Page Margins
Page margins are stored as a dictionary of four float values (top, right, bottom, left) in millimetres. They are applied as CSS padding on each .page div, determining the printable content area. Margins can be changed programmatically via setPageMargins() or interactively through the showMarginDialog() modal dialog, which provides four QDoubleSpinBox controls (range 0–50 mm, step 1 mm). When margins change, a full pagination recalculation is triggered via checkAllPages().
def setPageMargins(self, top: float, right: float, bottom: float, left: float)PublicUpdate page margins (CSS padding) in millimetres and recalculate page breaks.
def showMarginDialog(self)PublicOpen a modal dialog with spin boxes for editing page margins. Applies changes on OK.
File Open/Save Dialogs
def LoadFileDialog(self, caption='Open a file', dir='', filter='', dialog_type='open')PublicUnified file open/save dialog. Dispatches to the appropriate handler based on the file extension and dialog type. Supports .txt, .html, .pdf, and .docx formats for opening, and .txt, .html, .pdf, and .docx for saving.
def load_file(self, file_path: str = '')PublicRead a file from disk and return its contents as a string suitable for editor insertion. DOCX files are converted to HTML via pypandoc; PDF files are returned as base64; TXT files are read as UTF-8 text; HTML files are returned raw.
def save_file(self, file_path: str = "")PublicSave the current editor content to disk. TXT files use toPlainText(), HTML files use toHtml(), PDF files call savePdf(), and DOCX files call saveWithPandoc().
PDF Import
PDF import is handled by Mozilla's pdf.js library, which is lazily injected into the editor page only when a PDF file is first opened. The import pipeline works as follows:
- The raw PDF bytes are read from disk and encoded as base64.
_ensurePdfJsLoaded()checks whether pdf.js has already been injected; if not, it loads the base64-encodedpdf.min.jsandpdf.worker.min.jsfrom cached resources and injects them into the Chromium page viarunJavaScript().- Once the library is available,
_buildPdfRenderJs()generates JavaScript that decodes the base64 PDF data and renders each page sequentially. - Each PDF page is rendered to an off-screen canvas at 2× display scale for sharpness, converted to a PNG data-URL, and inserted into the editor as an
<div class="img-wrapper">element with eight resize handles. - The display width of each page image matches the editor page content width, with proportional height scaling.
def _ensurePdfJsLoaded(self, pdf_data_b64: str)PrivateInject pdf.js into the page if not already done, then render the PDF. Shows an in-editor error when resources are missing.
def _renderPdfPages(self, pdf_data_b64: str)PrivateRender PDF pages when pdf.js has already been loaded in a previous open operation.
def _buildPdfRenderJs(self, safe_pdfdata: str) -> strPrivateBuild the JavaScript source that decodes a PDF and inserts each page as an img-wrapper element.
PDF Export
PDF export uses Chromium's built-in QWebEnginePage.printToPdf() method, which renders the HTML document to a PDF file using the same Chromium rendering engine that displays it on screen. This ensures high fidelity between the on-screen preview and the exported document.
A QPageLayout object is constructed with the page size matching the editor's current setting and zero Qt margins — the CSS padding on the .page divs already provides the visual margins, and adding Qt margins on top would double the margin space. The print CSS media query ensures each page fills the printed page width exactly with width:100%; height:auto and uses page-break-after: always for correct page splitting.
def savePdf(self, filepath: str)PublicExport the document as a PDF file. Prepares print breaks, waits 100 ms for layout to settle, then calls printToPdf() with the appropriate page layout and zero margins.
Pandoc Export (DOCX / ODT)
The saveWithPandoc() method enables export to DOCX, ODT, and other formats supported by Pandoc. It first checks the HAS_PANDOC flag to ensure pypandoc is available, then retrieves the current document HTML synchronously via getHtmlContentSync(), writes it to a temporary file, and passes it to pypandoc.convert_file() for conversion. The output format is automatically determined from the file extension. The temporary file is deleted after conversion regardless of success or failure.
def saveWithPandoc(self, filePath: str)PublicExport editor content via pypandoc. Supports DOCX, ODT, and any Pandoc-supported output format. Displays a warning dialog if pypandoc is not installed.
Math Preprocessing & Formula Preparation
The editor provides two complementary methods for handling LaTeX math content in imported HTML:
_preprocess_math_html() — MathJax Script Conversion
When importing HTML documents that were originally authored for MathJax, the <script type="math/tex"> elements would be stripped by the browser's innerHTML parser before KaTeX could see them. This static method converts MathJax script tags to plain-text LaTeX delimiters before the HTML reaches the browser:
<script type="math/tex; mode=display">...</script>→$$...$$<script type="math/tex">...</script>→\(...\)
prepare_math_formulas() — LaTeX Delimiter to Placeholder Conversion
After MathJax scripts have been converted (or for HTML that already uses standard LaTeX delimiters), this method scans the HTML and replaces all recognised delimiter patterns with lightweight placeholder <span> elements that the editor's KaTeX rendering pipeline understands:
| Delimiter | Mode | Output Element |
|---|---|---|
$$...$$ | Display (block) | <span class="math-formula" data-display="block" data-latex="..."> |
\[...\] | Display (block) | <span class="math-formula" data-display="block" data-latex="..."> |
\(...\) | Inline | <span class="math-formula" data-display="inline" data-latex="..."> |
$...$ | Inline | <span class="math-formula" data-display="inline" data-latex="..."> |
Structure Synchronization
The structure_synchronization() method is a critical import pipeline component that merges an external HTML document into the editor's template without breaking the editor's internal structure. It performs the following steps in order:
- Native document check: If the imported HTML already contains the
<meta name="abdh-editor">marker, it is a native editor document and needs no synchronization. - Extract styles: All
<style>blocks are extracted from the imported document. - Extract body: The
<body>content is extracted, or the entire document is used if no body tag exists. - Remove external scripts: All
<script>tags are stripped from the body to prevent external JavaScript from executing inside the editor. - Rename conflicting classes: Any CSS class that matches an editor-reserved name (such as
page,katex,img-wrapper, etc.) is renamed by prependingEXT(e.g.,.pagebecomes.EXTpage). - Convert math formulas: LaTeX delimiters in the body are converted to
math-formulaplaceholder spans viaprepare_math_formulas(). - Inject into template: A fresh editor HTML template is built, the synchronized external styles are injected before
</head>, and the body content is inserted into the first.pagediv.
Reserved Classes The following CSS class names are reserved by the editor and will be renamed with an EXT prefix when found in imported documents: math-formula, math-editor, pages-wrapper, page, readonly-overlay, img-wrapper, resize-handle, handles-visible, handle-nw, handle-n, handle-ne, handle-e, handle-se, handle-s, handle-sw, handle-w, col-resize-handle, katex-error, katex, katex-display, katex-html, katex-mathml.
Page Size & Navigation
def setPageSize(self, size_name: str)PublicSwitch all editor pages to a different paper size. Updates the CSS dimensions of every .page element and immediately recalculates page overflow. Raises ValueError for unknown size names.
def getPageCount(self, callback)PublicAsynchronously retrieve the number of .page elements. The result is passed to callback as an integer.
def scrollToPage(self, page_num: int)PublicSmoothly scroll the viewport so that the requested 1-based page number is visible at the top. Page numbers are clamped to the valid range.
def refreshPageBreaks(self)PublicForce an immediate recalculation of page overflow/underflow and reattach MutationObservers.
Editability Toggle
def setEditable(self, b: bool = True)PublicToggle between editable and read-only mode. When b=False, a transparent overlay (#readonly-overlay) is shown over the editor, blocking all mouse events. When b=True, the overlay is hidden and editing is allowed.
Text Formatting
The editor provides a comprehensive set of text formatting methods that all delegate to the applyTextStyle() helper, which in turn executes the corresponding document.execCommand() call inside Chromium. The following table summarizes the available formatting operations:
| Method | execCommand | Description |
|---|---|---|
setBold() | bold | Toggle bold formatting on selection |
setItalic() | italic | Toggle italic formatting on selection |
setUnderline() | underline | Toggle underline formatting on selection |
setStrikeOut() | strikeThrough | Toggle strikethrough on selection |
setFontFamily(family) | fontName | Set CSS font family on selection |
setTextColor(color) | foreColor | Set text colour (QColor input) |
setTextBackgroundColor(color) | hiliteColor | Set background highlight colour (QColor) |
setFontSize(size) | Custom JS | Wrap selection in <span style="font-size:Npt">; expands collapsed selections to current word |
def chooseTextColor(self)PublicOpen a native colour picker dialog and apply the chosen colour as the text foreground.
def chooseBackgroundColor(self)PublicOpen a native colour picker dialog and apply the chosen colour as the text background highlight.
Alignment & Text Direction
def setAlignment(self, align: str)PublicSet paragraph alignment on the nearest block-level ancestor of the current selection. Accepts 'left', 'center', 'right', or 'justify'. If the cursor is directly inside a page div, a wrapping <p> element is created first.
def setParagraphDirection(self, rtl: bool)PublicSet the dir attribute of the nearest block ancestor to 'rtl' or 'ltr'. Useful for bidirectional text editing in Arabic, Hebrew, and Persian.
Insert Formula / Table / Image
Image Insertion
insertImage() opens a native file dialog filtered to common raster formats (PNG, JPG, JPEG, BMP, GIF), reads the selected file, encodes it as base64, and inserts a draggable img-wrapper element at the current cursor position. The wrapper includes eight directional resize handles (nw, n, ne, e, se, s, sw, w) that allow the user to resize the image by dragging from any corner or edge.
Math Formula Insertion
insertMath(latex, display=False) inserts a KaTeX-rendered formula span at the cursor. The formula is created via the JavaScript createMathFormula() function, which produces a <span class="math-formula"> element with contentEditable='false'. Double-clicking on a rendered formula replaces it with a text input for live editing; pressing Enter or blurring the input re-renders the formula with the updated LaTeX source.
insertMathDialog() provides a convenience dialog (QInputDialog) for entering LaTeX text, then calls insertMath() with the result.
Table Insertion
insertTable(rows, cols) builds an HTML table with the specified dimensions and inserts it at the cursor. Column resize handles are automatically attached via setupColumnResize(). insertTableDialog() provides a dialog with spin boxes for choosing the dimensions (rows: 1–30, columns: 1–15, both defaulting to 3).
Private helpers _insert_row(where) and _insert_col(where) insert a new row or column relative to the current cursor position, used by the table context menu.
Content Get / Set / Clear
| Method | Return Type | Description |
|---|---|---|
setText(raw_string, auto_render=True) | None | Replace entire document content. HTML mode (auto_render=True) renders KaTeX and checks pagination; plain-text mode (False) inserts via textContent with no processing. |
appendText(html, auto_render=True) | None | Append HTML to the last page. Optionally pre-processes math and triggers rendering. |
clearContent() | None | Remove all pages and reset to a single empty contenteditable page. |
getPlainText(callback) | None (async) | Asynchronously retrieve the concatenated text content of all pages. |
getHtmlContent(callback) | None (async) | Asynchronously retrieve the concatenated inner HTML of all pages. |
getPlainTextSync() | str | Blocking version of getPlainText() using QEventLoop. |
getHtmlContentSync() | str | Blocking version of getHtmlContent() using QEventLoop. |
getFullHtmlAsync() | str | Blocking retrieval of the complete page HTML including all <style> elements. |
Zoom Controls
| Method | Description |
|---|---|
zoomIn() | Increase zoom by 10% (multiplies current factor by 1.1) |
zoomOut() | Decrease zoom by 10% (divides current factor by 1.1) |
setZoomPercent(percent) | Set zoom to an exact percentage (e.g., 100 = 1.0×) |
fitPage() | Automatically zoom so the entire page fits inside the viewport, with 20 px margin. Clamped to 0.1×–5.0×. |
on_zoom_changed(text) | Slot for a zoom-percentage combo box; parses "100%" and calls setZoomPercent(). |
HTML Template Builder
The _build_initial_html() method assembles the complete HTML document that serves as the editor's editing surface. This is the largest single method in the file, spanning approximately 1,400 lines and containing the full CSS stylesheet, seven JavaScript engines, and the initial page structure. The template is parameterised by the current page size (width and height in mm) and page margins.
The HTML structure consists of:
- A
<head>section with a meta tag identifying the document as an editor file, embedded KaTeX tags (or fallback HTML comments), and the full CSS stylesheet. - A
<body>containing a<div class="pages-wrapper">with one initial<div class="page" contenteditable="true">and a<div id="readonly-overlay">. - A
<script>section containing all seven JavaScript engines.
CSS Overview
| Selector | Purpose |
|---|---|
body | Dark background (#0b1120), horizontal scroll for wide pages |
html scrollbar | Thin custom scrollbar styling for WebKit and Firefox |
.math-formula | Inline-block, clickable, LTR direction |
.math-editor | Monospace input styling for LaTeX editing |
.pages-wrapper | Flex column layout, centered, 12 px gap between pages |
.page | White background, box shadow, fixed mm dimensions, box-sizing border-box, overflow hidden |
.readonly-overlay | Absolute-positioned transparent overlay for read-only mode |
@media print | Print-specific styles: no shadows, auto height, page-break-after, visible overflow |
.img-wrapper | Floating, relative-positioned container for draggable images |
.resize-handle | 8-directional handles (nw, n, ne, e, se, s, sw, w), hidden by default, shown on hover via .handles-visible |
table, td, th | Collapse borders, 1 px solid #aaa, relative positioning for column resize handles |
.col-resize-handle | Absolute-positioned 5 px strip at cell right edge for column width adjustment |
.katex-error | Red text on pink background for KaTeX rendering errors |
Embedded JavaScript Engines
The editor HTML template contains seven self-contained JavaScript modules that handle all interactive editing behaviour inside the Chromium document. Each module is wrapped in an IIFE (Immediately Invoked Function Expression) or attached to the document/window object as appropriate.
Engine 1: Drag & Resize Engine
Manages drag-and-drop repositioning and 8-directional resizing of .img-wrapper elements. Uses margin-based positioning (marginLeft / marginTop) for overlap-free image movement within the content flow. When the user clicks on a resize handle, the engine tracks the mouse delta from the initial click position and applies proportional width/height changes while adjusting margins to keep opposite corners anchored. Resize handles are shown/hidden via CSS class toggling on mouseenter/mouseleave events, since CSS :hover is unreliable inside contenteditable regions.
Engine 2: Table Column Resize
The setupColumnResize(table) function adds invisible .col-resize-handle divs to each cell in the first row of a table. These handles capture mousedown events and track horizontal mouse movement to adjust column widths across all rows simultaneously. Tables are set to table-layout: fixed to ensure consistent column widths. The resize state is tracked via the resizingColumn flag and associated variables.
Engine 3: Table Tab Navigation
A keydown listener intercepts the Tab key when the cursor is inside a table cell and moves focus to the next cell (or previous cell with Shift+Tab). This provides natural spreadsheet-like navigation within tables. When the last cell is reached, focus wraps around to the first cell, and vice versa.
Engine 4: Multi-Page Engine
This is the most complex JavaScript module and the backbone of the editor's multi-page layout. It manages the creation, overflow handling, and deletion of .page divs:
getPageGeometry()— Returns the CSS pixel dimensions and padding of a page element.createNewPage()— Creates a new empty.pagediv withcontentEditable='true'.pushOverflow(pageEl)— Moves overflowing block-level children from a page to the next page (or creates a new page if none exists). Preserves cursor position when the cursor's containing block is moved.pullUnderflow(pageEl)— Pulls content from the next page into the current page when there is available space. Never pulls from the currently focused page to avoid stealing the user's cursor.removeEmptyPagesAndFixFocus()— Removes empty trailing pages (never the first page) and restores cursor focus to the nearest remaining page.isPageEmpty(page)— Checks if a page contains only whitespace (excluding images, tables, and math formulas which are never considered empty).checkAllPages()— The main orchestration function: pushes overflow on all pages, pulls underflow in reverse order, removes empty pages, updates page count and page-number attributes, and restores focus. Includes a re-entrancy guard (_pageCheckRunning) and scroll-position preservation.schedulePageCheck()— Debounced version usingrequestAnimationFrame().setupPageObserver(pageEl)— Attaches aMutationObserverto each page that triggersschedulePageCheck()on DOM changes.
Engine 5: Advanced Keyboard Handler
Overrides several key behaviours inside the contenteditable to handle non-editable elements (math formulas, images), multi-page navigation, and edge cases that Chromium's default contenteditable handling does not address:
| Key / Combo | Behaviour |
|---|---|
Ctrl+A | Select all content across all pages (not just the current page) |
Ctrl+Z / Ctrl+Y | Undo / Redo via execCommand |
Backspace / Delete | Delete non-editable nodes (formulas, images) when adjacent; reflow pages after deletion |
Backspace at page start | Move cursor to previous page or merge empty current page into previous |
Delete at page end | Move cursor to next page or remove empty next page |
Arrow Left/Right | Skip over non-editable elements instead of entering them |
Arrow Up at page top | Move focus to previous page |
Arrow Down at page bottom | Move focus to next page |
Enter | Insert a <div><br></div> block; handle empty block exit; special handling for list items |
Home / End | Skip over non-editable elements |
Engine 6: KaTeX Rendering Engine
Provides three key functions for mathematical formula handling:
renderMathFormula(element)— Renders a single.math-formulaspan using KaTeX, reading the LaTeX source fromdata-latexand the display mode fromdata-display.renderAllMathFormulas()— Iterates over all.math-formulaelements and renders each one.createMathFormula(latex, displayMode)— Creates a new.math-formulaspan, renders it with KaTeX, and returns the DOM element.
Double-click editing is implemented via a dblclick event listener that replaces the rendered span with a <input class="math-editor"> element. Pressing Enter or blurring the input creates a new formula span with the updated LaTeX and re-renders it.
Engine 7: Dirty Flag Bridge
A JavaScript input event listener on the document body detects all user edits (typing, deleting, formatting changes) and sets document.title to "__edit__" + Date.now(). This communicates the modification to Python via the titleChanged signal, which is connected to the _on_title_changed() slot that sets _is_dirty = True. The timestamp ensures that even rapid successive edits trigger distinct title changes.
Shortcut File Loaders
The editor provides four convenience methods that combine a file dialog with the appropriate loading logic for each supported format:
def loadTextFile(self, auto_render=False)PublicOpen a file dialog filtered to .txt files, read the content, and insert it as plain text (or optionally rendered HTML).
def loadHtmlFile(self)PublicOpen a file dialog filtered to .html / .htm files, synchronise the structure, and load it into the editor with post-load math rendering and pagination.
def loadDocxFile(self)PublicOpen a file dialog filtered to .docx files, convert via pypandoc, synchronise the structure, and load it into the editor.
def loadPdfFile(self)PublicOpen a file dialog filtered to .pdf files, read and base64-encode the content, and render via pdf.js.
Complete API Reference
The following table lists every public method exposed by the RichTextEditor class, grouped by functional category. Private methods (prefixed with _) are documented in the sections above but are omitted here as they are not part of the stable public interface.
Document State
| Method | Signature | Returns |
|---|---|---|
| isModified | isModified() -> bool | True if unsaved changes exist |
| setClean | setClean() | None |
| setEditable | setEditable(b: bool = True) | None |
Page Layout
| Method | Signature | Returns |
|---|---|---|
| setPageSize | setPageSize(size_name: str) | None |
| setPageMargins | setPageMargins(top, right, bottom, left: float) | None |
| showMarginDialog | showMarginDialog() | None |
| getPageCount | getPageCount(callback) | None (async) |
| scrollToPage | scrollToPage(page_num: int) | None |
| refreshPageBreaks | refreshPageBreaks() | None |
Content
| Method | Signature | Returns |
|---|---|---|
| setText | setText(raw_string: str, auto_render: bool = True) | None |
| appendText | appendText(html: str, auto_render: bool = True) | None |
| clearContent | clearContent() | None |
| getPlainText | getPlainText(callback) | None (async) |
| getHtmlContent | getHtmlContent(callback) | None (async) |
| getPlainTextSync | getPlainTextSync() -> str | Plain text content |
| getHtmlContentSync | getHtmlContentSync() -> str | HTML content |
| getFullHtmlAsync | getFullHtmlAsync() -> str | Complete page HTML |
Text Formatting
| Method | Signature | Returns |
|---|---|---|
| setBold | setBold(_: bool = True) | None |
| setItalic | setItalic(_: bool = True) | None |
| setUnderline | setUnderline(_: bool = True) | None |
| setStrikeOut | setStrikeOut(_: bool = True) | None |
| setFontFamily | setFontFamily(family: str) | None |
| setFontSize | setFontSize(size: int) | None |
| setTextColor | setTextColor(color: QColor) | None |
| setTextBackgroundColor | setTextBackgroundColor(color: QColor) | None |
| chooseTextColor | chooseTextColor() | None |
| chooseBackgroundColor | chooseBackgroundColor() | None |
Alignment & Direction
| Method | Signature | Returns |
|---|---|---|
| setAlignment | setAlignment(align: str) | None |
| setParagraphDirection | setParagraphDirection(rtl: bool) | None |
Insert
| Method | Signature | Returns |
|---|---|---|
| insertImage | insertImage() | None |
| insertMath | insertMath(latex: str, display: bool = False) | None |
| insertMathDialog | insertMathDialog() | None |
| insertTable | insertTable(rows: int, cols: int) | None |
| insertTableDialog | insertTableDialog() | None |
Zoom
| Method | Signature | Returns |
|---|---|---|
| zoomIn | zoomIn() | None |
| zoomOut | zoomOut() | None |
| setZoomPercent | setZoomPercent(percent: int) | None |
| fitPage | fitPage() | None |
File Operations
| Method | Signature | Returns |
|---|---|---|
| LoadFileDialog | LoadFileDialog(caption, dir, filter, dialog_type) | None |
| load_file | load_file(file_path: str = '') | File data (str or base64) |
| save_file | save_file(file_path: str = "") | None |
| savePdf | savePdf(filepath: str) | None |
| saveWithPandoc | saveWithPandoc(filePath: str) | None |
| extractAsImages | extractAsImages() | None |
| loadTextFile | loadTextFile(auto_render=False) | None |
| loadHtmlFile | loadHtmlFile() | None |
| loadDocxFile | loadDocxFile() | None |
| loadPdfFile | loadPdfFile() | None |
Import Helpers
| Method | Signature | Returns |
|---|---|---|
| is_native_document | is_native_document(html_content='') -> bool | True if HTML is a native editor document |
| structure_synchronization | structure_synchronization(imported_html: str) -> str | Synchronised HTML |
| prepare_math_formulas | prepare_math_formulas(source_html: str) -> str | HTML with math placeholders |
Note The editor uses document.execCommand() for basic formatting and custom JS for advanced features. For a complete list of reserved CSS classes (renamed during import), refer to the structure_synchronization() documentation.