`` → Removed (empty string)
+
+Field Formatting Functions
+--------------------------
+
+The module includes these field formatting functions:
+
+* ``A(value, length=None)`` - Alphanumeric: left-aligned, space-padded
+* ``N(value, length=None, digits=None, sign=False)`` - Numeric: right-aligned,
+ zero-padded, optional sign at the end
+* ``M(value, length=None)`` - Monetary: calls ``N`` with ``digits=2`` and ``sign=True``
+* ``T(value, length=None, digits=2)`` - Tax: calls ``N`` with ``digits=2`` and ``sign=False``
+* ``D(value, length=8, dtformat="%Y%m%d")`` - Date: formats dates with
+ customizable format, defaults to yyyymmdd
+* ``H(value, length=4, dtformat="%H%M")`` - Time: formats times with
+ customizable format, defaults to hhmm
+* ``DT(value, length=12, dtformat="%Y%m%d%H%M")`` - Datetime: formats
+ datetime with customizable format, defaults to yyyyMMddhhmm
+
+Post-processing Behavior
+------------------------
+
+The module automatically performs the following post-processing on text
+content:
+
+1. Strips whitespace from each line
+2. Removes all line breaks from QWeb template structure
+3. Removes HTML elements (SPAN, DIV) completely
+4. Replaces special elements with their defined characters
+5. Returns single-line output with controlled formatting
+
+This ensures clean, consistent text output regardless of QWeb template
+formatting or HTML elements.
diff --git a/report_qweb_text_control/readme/CONTRIBUTORS.rst b/report_qweb_text_control/readme/CONTRIBUTORS.rst
new file mode 100644
index 0000000000..b58d363e17
--- /dev/null
+++ b/report_qweb_text_control/readme/CONTRIBUTORS.rst
@@ -0,0 +1,5 @@
+This module was developed with AI assistance under close guidance and supervision
+from experienced Odoo developers. The development utilized Cascade (SWE-1.5) for
+code implementation and testing, with Claude AI providing architectural guidance
+and code quality review. All AI-generated code was thoroughly reviewed, validated,
+and approved by human supervisors.
diff --git a/report_qweb_text_control/readme/DESCRIPTION.rst b/report_qweb_text_control/readme/DESCRIPTION.rst
new file mode 100644
index 0000000000..ddbf63bbdc
--- /dev/null
+++ b/report_qweb_text_control/readme/DESCRIPTION.rst
@@ -0,0 +1,21 @@
+Allow text reports to generate positional text files by providing
+precise control over whitespace, special characters, and field formatting.
+
+Use Case
+--------
+
+Generate fixed-width text files for legacy systems, bank transfers, or any interface
+requiring exact positional formatting with specific characters and spacing.
+
+Example
+-------
+
+Template::
+
+
+
+
+
+Output::
+
+ John Doe 00000012340000015000+
diff --git a/report_qweb_text_control/readme/USAGE.rst b/report_qweb_text_control/readme/USAGE.rst
new file mode 100644
index 0000000000..0cf49be831
--- /dev/null
+++ b/report_qweb_text_control/readme/USAGE.rst
@@ -0,0 +1,98 @@
+To create a text-controlled QWeb report, define a report with
+``report_type="qweb-text"`` and enable the "Enable Text Control" option:
+
+.. code-block:: xml
+
+
+ Custom Text Report
+ your.model
+ qweb-text
+
+ your_module.report_custom_text
+
+ report
+
+
+Special Elements in Templates
+------------------------------
+
+In your QWeb template, use special elements for controlled output. HTML elements
+like SPAN and DIV are automatically removed:
+
+.. code-block:: xml
+
+
+
+ ,
+ ,
+
+
+
+
+Field Formatting
+-----------------
+
+The module provides field formatting functions available in templates:
+
+.. code-block:: xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Customization
+-------------
+
+Override the ``_get_replacement_elements()`` method to define your own special
+elements:
+
+.. code-block:: python
+
+ from odoo import models
+
+ class CustomTextReport(models.Model):
+ _inherit = "ir.actions.report"
+
+ def _get_replacement_elements(self):
+ """Override to customize replacement elements"""
+ elements = super()._get_replacement_elements()
+ elements.update({
+ "CUSTOM": "CustomValue",
+ "NEWLINE": "\r\n",
+ })
+ return elements
diff --git a/report_qweb_text_control/report/__init__.py b/report_qweb_text_control/report/__init__.py
new file mode 100644
index 0000000000..1bcfad7bf4
--- /dev/null
+++ b/report_qweb_text_control/report/__init__.py
@@ -0,0 +1,3 @@
+# Copyright 2025 Open Source Integrators
+from . import field_formatter
+from . import report_qweb_text_control
diff --git a/report_qweb_text_control/report/field_formatter.py b/report_qweb_text_control/report/field_formatter.py
new file mode 100644
index 0000000000..6086a71286
--- /dev/null
+++ b/report_qweb_text_control/report/field_formatter.py
@@ -0,0 +1,107 @@
+# Copyright 2025 Open Source Integrators
+# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
+
+from odoo import fields
+
+
+def A(value, length=None):
+ """
+ Format as alphanumeric (A): left-aligned.
+
+ Args:
+ value: Value to format
+ length: Fixed length for padding (optional)
+
+ Returns:
+ str: Formatted string
+ """
+ text = str(value or "")
+ if length:
+ text = text[:length]
+ text = text.ljust(length)
+ return text
+
+
+def N(value, length=None, digits=None, sign=False):
+ """
+ Format as numeric (N): right-aligned with left zero padding.
+
+ Args:
+ value: Value to format
+ length: Fixed length for padding (optional)
+ digits: Number of decimal digits (optional)
+ sign: Whether to include sign (+/-) at the end (optional)
+
+ Returns:
+ str: Formatted string
+ """
+ if value is None:
+ value = 0
+
+ # Handle decimal digits
+ if digits is not None:
+ # Multiply by 10^digits and round to get integer representation
+ multiplier = 10**digits
+ integer_value = int(round(abs(value) * multiplier))
+ text = str(integer_value)
+ else:
+ text = str(abs(int(value)))
+
+ # Apply padding (accounting for sign if requested)
+ if length:
+ padding_length = length - 1 if sign else length
+ text = text.zfill(padding_length)[-padding_length:]
+
+ # Add sign if requested (after padding)
+ if sign:
+ sign_char = "+" if value >= 0 else "-"
+ text += sign_char
+
+ return text
+
+
+def M(value, length=None):
+ """
+ Format as monetary (M): calls N with default digits=2 and sign=True.
+
+ Args:
+ value: Value to format
+ length: Fixed length for padding (optional)
+
+ Returns:
+ str: Formatted string
+ """
+ return N(value, length, digits=2, sign=True)
+
+
+def T(value, length=5, digits=2):
+ # Tax: number with two decimals and no sign
+ return N(value, length, digits=digits, sign=False)
+
+
+def DT(value, length=12, dtformat="%Y%m%d%H%M"):
+ """
+ Format as datetime (DT): formats datetime values with customizable format.
+
+ Args:
+ value: Datetime value to format
+ length: Fixed length for padding (default: 12 for yyyyMMddhhmm)
+ dtformat: Datetime format string (default: "%Y%m%d%H%M")
+
+ Returns:
+ str: Formatted datetime string
+ """
+ if not value:
+ value = fields.Datetime.now()
+ text = value.strftime(dtformat) if value else ""
+ return A(text, length)
+
+
+def D(value, length=8, dtformat="%Y%m%d"):
+ # Format as date yyymmdd
+ return DT(value, length=length, dtformat=dtformat)
+
+
+def H(value, length=4, dtformat="%H%M"):
+ # Format as hour 24h format HHMM
+ return DT(value, length=length, dtformat=dtformat)
diff --git a/report_qweb_text_control/report/report_qweb_text_control.py b/report_qweb_text_control/report/report_qweb_text_control.py
new file mode 100644
index 0000000000..afffc6a69c
--- /dev/null
+++ b/report_qweb_text_control/report/report_qweb_text_control.py
@@ -0,0 +1,83 @@
+# Copyright 2025 Open Source Integrators
+# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
+
+import re
+
+from odoo import fields, models
+
+from . import field_formatter
+
+
+class IrActionsReport(models.Model):
+ _inherit = "ir.actions.report"
+
+ text_control_enabled = fields.Boolean(
+ string="Enable Text Control",
+ help="Enable whitespace control and field formatting for text reports",
+ )
+
+ def _get_rendering_context(self, docids, data):
+ # Add field formatters to rendering context only when text control is enabled
+ if self.text_control_enabled:
+ context = super()._get_rendering_context(docids, data)
+ context.update(
+ {
+ "A": field_formatter.A,
+ "N": field_formatter.N,
+ "M": field_formatter.M,
+ "T": field_formatter.T,
+ "D": field_formatter.D,
+ "DT": field_formatter.DT,
+ "H": field_formatter.H,
+ }
+ )
+ return context
+ return super()._get_rendering_context(docids, data)
+
+ def _render_qweb_text(self, docids, data=None):
+ # Extend standard text rendering with post-processing only when text control is enabled
+ if self.text_control_enabled:
+ content, format_type = super()._render_qweb_text(docids, data)
+ processed_content = self._postprocess_text(content)
+ return processed_content, format_type
+ return super()._render_qweb_text(docids, data)
+
+ def _postprocess_text(self, content):
+ """Post-process text content to control whitespace and replace elements."""
+ # Ensure content is a string (decode if bytes)
+ if isinstance(content, bytes):
+ content = content.decode("utf-8")
+
+ # Strip spaces from each line first
+ lines = content.split("\n")
+ stripped_lines = [line.strip() for line in lines]
+ content = "\n".join(stripped_lines)
+
+ # Remove all unwanted line breaks (both \r and \n) first
+ content = re.sub(r"[\r\n]+", "", content)
+
+ # Then replace special elements with their corresponding characters
+ replacement_elements = self._get_replacement_elements()
+ for element, replacement in replacement_elements.items():
+ # Replace opening tags with the replacement character
+ pattern = (
+ f"<{element}>|<{element}/>|<{element.lower()}>|<{element.lower()}/>"
+ )
+ content = re.sub(pattern, replacement, content)
+ # Remove closing tags completely
+ pattern_close = f"{element}>|{element.lower()}>"
+ content = re.sub(pattern_close, "", content)
+
+ return content
+
+ def _get_replacement_elements(self):
+ """Return dictionary of special elements and their replacements."""
+ return {
+ "CR": "\r",
+ "LF": "\n",
+ "CRLF": "\r\n",
+ "TAB": "\t",
+ "SEMICOLON": ";",
+ "SPAN": "",
+ "DIV": "",
+ }
diff --git a/report_qweb_text_control/static/description/index.html b/report_qweb_text_control/static/description/index.html
new file mode 100644
index 0000000000..f79c70dc49
--- /dev/null
+++ b/report_qweb_text_control/static/description/index.html
@@ -0,0 +1,579 @@
+
+
+
+
+
+
QWeb Text Control
+
+
+
+
+
QWeb Text Control
+
+
+

+
Allow text reports to generate positional text files by providing
+precise control over whitespace, special characters, and field formatting.
+
+
Use Case
+
Generate fixed-width text files for legacy systems, bank transfers, or any interface
+requiring exact positional formatting with specific characters and spacing.
+
+
+
Example
+
Template:
+
+<span t-esc="A(partner.name, 20)"/>
+<span t-esc="N(partner.id, 10)"/>
+<span t-esc="M(partner.credit_limit, 12)"/>
+
+
Output:
+
+John Doe 00000012340000015000+
+
+
Table of contents
+
+
+
+
+
Default Replacement Elements
+
The module provides these default replacement elements:
+
+- <CR> → Carriage return (\r)
+- <LF> → Line feed (\n)
+- <CRLF> → Windows line ending (\r\n)
+- <TAB> → Tab character (\t)
+- <SEMICOLON> → Semicolon (;)
+- <SPAN> → Removed (empty string)
+- <DIV> → Removed (empty string)
+
+
+
+
+
Post-processing Behavior
+
The module automatically performs the following post-processing on text
+content:
+
+- Strips whitespace from each line
+- Removes all line breaks from QWeb template structure
+- Removes HTML elements (SPAN, DIV) completely
+- Replaces special elements with their defined characters
+- Returns single-line output with controlled formatting
+
+
This ensures clean, consistent text output regardless of QWeb template
+formatting or HTML elements.
+
+
Usage
+
To create a text-controlled QWeb report, define a report with
+report_type="qweb-text" and enable the “Enable Text Control” option:
+
+<record id="action_report_custom_text" model="ir.actions.report">
+ <field name="name">Custom Text Report</field>
+ <field name="model">your.model</field>
+ <field name="report_type">qweb-text</field>
+ <field name="text_control_enabled" eval="True"/>
+ <field name="report_name">your_module.report_custom_text</field>
+ <field name="binding_model_id" ref="model_your_model"/>
+ <field name="binding_type">report</field>
+</record>
+
+
+
+
+
Special Elements in Templates
+
In your QWeb template, use special elements for controlled output. HTML elements
+like SPAN and DIV are automatically removed:
+
+<template id="report_custom_text">
+ <t t-foreach="docs" t-as="doc">
+ <span t-esc="doc.name"/>,<span t-esc="doc.email"/><LF/>
+ <span t-esc="doc.phone"/>,<span t-esc="doc.address"/><CRLF/>
+ <span t-esc="doc.field1"/><TAB/><span t-esc="doc.field2"/><SEMICOLON/><span t-esc="doc.field3"/>
+ </t>
+</template>
+
+
+
+
+
Customization
+
Override the _get_replacement_elements() method to define your own special
+elements:
+
+from odoo import models
+
+class CustomTextReport(models.Model):
+ _inherit = "ir.actions.report"
+
+ def _get_replacement_elements(self):
+ """Override to customize replacement elements"""
+ elements = super()._get_replacement_elements()
+ elements.update({
+ "CUSTOM": "CustomValue",
+ "NEWLINE": "\r\n",
+ })
+ return elements
+
+
+
Bug Tracker
+
Bugs are tracked on GitHub Issues.
+In case of trouble, please check there if your issue has already been reported.
+If you spotted it first, help us to smash it by providing a detailed and welcomed
+feedback.
+
Do not contact contributors directly about support or help with technical issues.
+
+
+
Credits
+
+
Authors
+
+- Open Source Integrators
+
+
+
+
Contributors
+
This module was developed with AI assistance under close guidance and supervision
+from experienced Odoo developers. The development utilized Cascade (SWE-1.5) for
+code implementation and testing, with Claude AI providing architectural guidance
+and code quality review. All AI-generated code was thoroughly reviewed, validated,
+and approved by human supervisors.
+
+
+
Maintainers
+
This module is maintained by the OCA.
+
+
+
+
OCA, or the Odoo Community Association, is a nonprofit organization whose
+mission is to support the collaborative development of Odoo features and
+promote its widespread use.
+
This module is part of the OCA/reporting-engine project on GitHub.
+
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
+
+
+
+
+
+
diff --git a/report_qweb_text_control/tests/__init__.py b/report_qweb_text_control/tests/__init__.py
new file mode 100644
index 0000000000..8dd98744cd
--- /dev/null
+++ b/report_qweb_text_control/tests/__init__.py
@@ -0,0 +1,4 @@
+# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
+
+from . import test_field_formatter
+from . import test_report_integration
diff --git a/report_qweb_text_control/tests/test_field_formatter.py b/report_qweb_text_control/tests/test_field_formatter.py
new file mode 100644
index 0000000000..4f5199734a
--- /dev/null
+++ b/report_qweb_text_control/tests/test_field_formatter.py
@@ -0,0 +1,156 @@
+# Copyright 2025 Open Source Integrators
+# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
+
+
+from odoo.tests import TransactionCase, tagged
+
+from ..report import field_formatter
+
+
+@tagged("-at_install", "post_install")
+class TestFieldFormatter(TransactionCase):
+ """Test field formatting functions."""
+
+ def test_alphanumeric_formatter(self):
+ """Test alphanumeric field formatting."""
+ self.assertEqual(field_formatter.A(None, 5), " ")
+ self.assertEqual(field_formatter.A(123, 5), "123 ")
+ self.assertEqual(field_formatter.A("Test", 10), "Test ")
+ self.assertEqual(field_formatter.A("LongerText", 5), "Longe")
+
+ def test_numeric_formatter(self):
+ """Test numeric field formatting."""
+ # Test basic formatting
+ self.assertEqual(field_formatter.N(None), "0")
+ self.assertEqual(field_formatter.N(123), "123")
+ self.assertEqual(field_formatter.N(123, 8), "00000123")
+ self.assertEqual(field_formatter.N(123.45, 8), "00000123")
+
+ # Test with digits parameter
+ self.assertEqual(field_formatter.N(123.456, 8, digits=2), "00012346")
+ self.assertEqual(field_formatter.N(123.456, 8, digits=3), "00123456")
+
+ # Test with sign parameter
+ self.assertEqual(field_formatter.N(123, 8, sign=True), "0000123+")
+ self.assertEqual(field_formatter.N(-123, 8, sign=True), "0000123-")
+ self.assertEqual(field_formatter.N(-123, 8, sign=False), "00000123")
+
+ # Test edge cases
+ self.assertEqual(field_formatter.N(-123, 8, sign=True), "0000123-")
+ self.assertEqual(field_formatter.N(0, 5), "00000")
+
+ def test_monetary_formatter(self):
+ """Test monetary field formatting."""
+ # Test basic formatting
+ self.assertEqual(field_formatter.M(100.50, 10), "000010050+")
+ self.assertEqual(field_formatter.M(-100.50, 10), "000010050-")
+ self.assertEqual(field_formatter.M(0, 8), "0000000+")
+
+ # Test with None length
+ self.assertEqual(field_formatter.M(100.50), "10050+")
+
+ # Test rounding
+ self.assertEqual(field_formatter.M(100.456, 10), "000010046+")
+ self.assertEqual(field_formatter.M(100.454, 10), "000010045+")
+
+ def test_date_formatter(self):
+ """Test date field formatting."""
+ from datetime import date, datetime
+
+ # Test with datetime object
+ dt = datetime(2025, 1, 15, 10, 30, 45)
+ self.assertEqual(field_formatter.D(dt), "20250115")
+
+ # Test with date object
+ d = date(2025, 1, 15)
+ self.assertEqual(field_formatter.D(d), "20250115")
+
+ # Test with custom format
+ self.assertEqual(field_formatter.D(dt, 10, dtformat="%Y-%m-%d"), "2025-01-15")
+ self.assertEqual(field_formatter.D(dt, 10, dtformat="%d/%m/%Y"), "15/01/2025")
+
+ # Test with None - should use current datetime and format as date
+ from odoo import fields
+
+ current_dt = fields.Datetime.now()
+ expected_current_date = current_dt.strftime("%Y%m%d")
+ self.assertEqual(field_formatter.D(None, None), expected_current_date)
+
+ # Test with length
+ self.assertEqual(field_formatter.D(dt, 12), "20250115 ")
+ self.assertEqual(field_formatter.D(dt, 12, dtformat="%Y-%m-%d"), "2025-01-15 ")
+
+ def test_H_formatter(self):
+ """Test H field formatting (time)."""
+ from datetime import datetime, time
+
+ # Test with datetime object
+ dt = datetime(2025, 1, 15, 14, 30, 45)
+ self.assertEqual(field_formatter.H(dt), "1430")
+
+ # Test with time object
+ t = time(14, 30, 45)
+ self.assertEqual(field_formatter.H(t), "1430")
+
+ # Test with custom format
+ self.assertEqual(field_formatter.H(dt, 5, dtformat="%H:%M"), "14:30")
+ self.assertEqual(field_formatter.H(dt, None, dtformat="%I:%M %p"), "02:30 PM")
+
+ # Test with None - should use current datetime and format as time
+ from odoo import fields
+
+ current_dt = fields.Datetime.now()
+ expected_current_time = current_dt.strftime("%H%M")
+ self.assertEqual(field_formatter.H(None), expected_current_time)
+
+ # Test with length
+ self.assertEqual(field_formatter.H(dt, 6), "1430 ")
+ self.assertEqual(field_formatter.H(dt, 8, dtformat="%H:%M:%S"), "14:30:45")
+
+ def test_T_formatter(self):
+ """Test T field formatting (tax)."""
+ # Test basic tax formatting without sign (default length=5, digits=2)
+ self.assertEqual(field_formatter.T(123.45), "12345")
+ self.assertEqual(field_formatter.T(0), "00000")
+ self.assertEqual(field_formatter.T(-123.45), "12345") # No sign
+
+ # Test with explicit length
+ self.assertEqual(field_formatter.T(123.45, 6), "012345")
+
+ # Test with custom digits
+ self.assertEqual(field_formatter.T(23.456, digits=3), "23456")
+ self.assertEqual(field_formatter.T(123.45, digits=0), "00123")
+
+ # Test with None
+ self.assertEqual(field_formatter.T(None), "00000")
+
+ def test_datetime_formatter(self):
+ """Test datetime field formatting."""
+ from datetime import datetime
+
+ # Test with datetime object
+ dt = datetime(2025, 1, 15, 14, 30, 45)
+ self.assertEqual(field_formatter.DT(dt), "202501151430")
+
+ # Test with custom format
+ self.assertEqual(
+ field_formatter.DT(dt, None, dtformat="%Y-%m-%d %H:%M"), "2025-01-15 14:30"
+ )
+ self.assertEqual(
+ field_formatter.DT(dt, None, dtformat="%Y%m%d_%H%M%S"), "20250115_143045"
+ )
+
+ # Test with None - should use current datetime
+ from odoo import fields
+
+ current_dt = fields.Datetime.now()
+ expected_current = current_dt.strftime("%Y%m%d%H%M")
+ self.assertEqual(field_formatter.DT(None), expected_current)
+ self.assertEqual(field_formatter.DT(None, None), expected_current)
+
+ # Test with length
+ self.assertEqual(field_formatter.DT(dt, 16), "202501151430 ")
+ self.assertEqual(
+ field_formatter.DT(dt, 19, dtformat="%Y-%m-%d %H:%M:%S"),
+ "2025-01-15 14:30:45",
+ )
diff --git a/report_qweb_text_control/tests/test_report_integration.py b/report_qweb_text_control/tests/test_report_integration.py
new file mode 100644
index 0000000000..08d96a2a6d
--- /dev/null
+++ b/report_qweb_text_control/tests/test_report_integration.py
@@ -0,0 +1,170 @@
+# Copyright 2025 Open Source Integrators
+# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
+
+from datetime import datetime
+
+from odoo.tests import TransactionCase, tagged
+from odoo.tools.convert import xml_import
+
+
+@tagged("-at_install", "post_install")
+class TestReportIntegration(TransactionCase):
+ """Test report integration with demo report."""
+
+ def setUp(self):
+ super().setUp()
+ # Load demo report data using Odoo's XML import
+ self._load_demo_report()
+
+ # Create test partner
+ self.test_partner = self.env["res.partner"].create(
+ {
+ "name": "Test Partner",
+ "email": "test@example.com",
+ "phone": "+1234567890",
+ "company_type": "company",
+ "credit_limit": 1500.50,
+ "country_id": self.env.ref("base.us").id,
+ }
+ )
+
+ def _load_demo_report(self):
+ """Load demo report data using Odoo's XML import utility."""
+ # Use Odoo's xml_import to load the demo data
+ xml_import(
+ self.env.cr,
+ "report_qweb_text_control",
+ "report_qweb_text_control/demo/partner_report.xml",
+ mode="init",
+ noupdate=True,
+ )
+
+ # Get the demo report that was created
+ self.demo_report = self.env.ref(
+ "report_qweb_text_control.action_report_partner_text"
+ )
+
+ def test_field_formatters_in_context(self):
+ """Test that field formatters are available in render context."""
+ report = self.demo_report
+ context = report._get_rendering_context([self.test_partner.id], {})
+
+ # Test formatter functions
+ self.assertEqual(context["A"]("Test", 10), "Test ")
+ self.assertEqual(context["N"](123, 8), "00000123")
+ self.assertEqual(context["M"](100.50, 10), "000010050+")
+ test_date = datetime(2025, 1, 15)
+ self.assertEqual(context["D"](test_date), "20250115")
+ test_datetime = datetime(2025, 1, 15, 14, 30, 45)
+ self.assertEqual(context["H"](test_datetime), "1430")
+ self.assertEqual(
+ context["T"](123.45), "12345"
+ ) # Tax formatter with default length=5
+ self.assertEqual(context["DT"](test_datetime), "202501151430")
+
+ def test_report_rendering(self):
+ """Test that the report renders correctly with post-processing."""
+ report = self.demo_report
+
+ # Render the report
+ content, format_type = report._render_qweb_text([self.test_partner.id])
+
+ # Check format type
+ self.assertEqual(format_type, "text")
+
+ # Check that content is a string
+ self.assertIsInstance(content, str)
+
+ # Check that special elements are replaced
+ self.assertIn("\r\n", content) # CRLF elements
+
+ # Check that field formatting works
+ self.assertIn("Test Partner", content)
+ self.assertIn("test@example.com", content)
+ self.assertIn("+1234567890", content)
+ self.assertIn("United States", content)
+ self.assertIn("0010000+", content) # Company monetary format
+ self.assertIn("0000150050+", content) # Credit limit with sign
+
+ def test_postprocess_text_replacement_elements(self):
+ """Test text post-processing with replacement elements."""
+ report = self.demo_report
+
+ # Test content with various special elements
+ test_content = "Field1
Field2
Field3
Field4"
+ processed = report._postprocess_text(test_content)
+
+ expected = "Field1\tField2\r\nField3;Field4"
+ self.assertEqual(processed, expected)
+
+ def test_postprocess_text_removes_line_breaks(self):
+ """Test that unwanted line breaks are removed."""
+ report = self.demo_report
+
+ # Test content with unwanted line breaks
+ test_content = "Line1\nLine2\r\nLine3\rLine4\nLine5"
+ processed = report._postprocess_text(test_content)
+
+ # Unwanted line breaks should be removed, special elements preserved
+ self.assertNotIn("\n", processed)
+ self.assertNotIn("\r", processed)
+
+ def test_postprocess_text_handles_bytes(self):
+ """Test that bytes input is handled correctly."""
+ report = self.demo_report
+
+ # Test bytes input
+ test_content = b"Field1
Field2
Field3"
+ processed = report._postprocess_text(test_content)
+
+ expected = "Field1\tField2\r\nField3"
+ self.assertEqual(processed, expected)
+
+ def test_replacement_elements_configuration(self):
+ """Test that replacement elements can be customized."""
+ report = self.demo_report
+
+ # Get default replacement elements
+ elements = report._get_replacement_elements()
+
+ # Check default elements
+ self.assertEqual(elements["CR"], "\r")
+ self.assertEqual(elements["LF"], "\n")
+ self.assertEqual(elements["CRLF"], "\r\n")
+ self.assertEqual(elements["TAB"], "\t")
+ self.assertEqual(elements["SEMICOLON"], ";")
+
+ def test_case_insensitive_elements(self):
+ """Test that elements work case-insensitively."""
+ report = self.demo_report
+
+ # Test mixed case elements
+ test_content = "Field1
field2
Field3
field4
"
+ processed = report._postprocess_text(test_content)
+
+ expected = "Field1\tfield2\tField3\rfield4\r"
+ self.assertEqual(processed, expected)
+
+ def test_regular_text_report_no_formatters(self):
+ """
+ Test that regular text reports don't have field formatters
+ when text control is disabled.
+ """
+ # Create a regular text report with text control disabled
+ regular_report = self.env["ir.actions.report"].create(
+ {
+ "name": "Regular Text Report",
+ "model": "res.partner",
+ "report_type": "qweb-text",
+ "report_name": "test_regular_text_report",
+ "text_control_enabled": False,
+ }
+ )
+
+ # Get rendering context - should not have field formatters
+ context = regular_report._get_rendering_context([self.test_partner.id], {})
+
+ # Field formatters should not be available
+ self.assertNotIn("A", context)
+ self.assertNotIn("N", context)
+ self.assertNotIn("DT", context)
diff --git a/report_qweb_text_control/tests/test_report_qweb_text_control.py b/report_qweb_text_control/tests/test_report_qweb_text_control.py
new file mode 100644
index 0000000000..b21ba67f8b
--- /dev/null
+++ b/report_qweb_text_control/tests/test_report_qweb_text_control.py
@@ -0,0 +1,139 @@
+# Copyright 2025 Open Source Integrators
+# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
+
+from odoo.tests import TransactionCase, tagged
+from odoo.tools.convert import xml_import
+
+
+@tagged("-at_install", "post_install")
+class TestQWebTextControl(TransactionCase):
+ """Test QWeb Text Control functionality."""
+
+ def setUp(self):
+ super().setUp()
+ # Load demo report data using Odoo's XML import
+ self._load_demo_report()
+
+ # Create test partner
+ self.test_partner = self.env["res.partner"].create(
+ {
+ "name": "Test Partner",
+ "email": "test@example.com",
+ "phone": "+1234567890",
+ "company_type": "company",
+ "credit_limit": 1500.50,
+ "country_id": self.env.ref("base.us").id,
+ }
+ )
+
+ def _load_demo_report(self):
+ """Load demo report data using Odoo's XML import utility."""
+ # Use Odoo's xml_import to load the demo data
+ xml_import(
+ self.env.cr,
+ "report_qweb_text_control",
+ "report_qweb_text_control/demo/partner_report.xml",
+ mode="init",
+ noupdate=True,
+ )
+
+ # Get the demo report that was created
+ self.demo_report = self.env.ref(
+ "report_qweb_text_control.action_report_partner_text"
+ )
+
+ def test_field_formatters_in_context(self):
+ """Test that field formatters are available in render context."""
+ report = self.demo_report
+ context = report._get_rendering_context([self.test_partner.id], {})
+ # Test formatter functions
+ self.assertEqual(context["A"]("Test", 10), "Test ")
+ self.assertEqual(context["N"](123, 8), "00000123")
+ self.assertEqual(context["M"](100.50, 10), "0000010050+")
+ self.assertEqual(context["T"](0.15, 8), "00000150")
+
+ def test_report_rendering(self):
+ """Test that the report renders correctly with post-processing."""
+ report = self.demo_report
+
+ # Render the report
+ content, format_type = report._render_qweb_text([self.test_partner.id])
+
+ # Check format type
+ self.assertEqual(format_type, "text")
+
+ # Check that content is a string
+ self.assertIsInstance(content, str)
+
+ # Check that special elements are replaced
+ self.assertIn("|", content) # PIPE elements
+ self.assertIn("\r\n", content) # CRLF elements
+
+ # Check that field formatting works
+ self.assertIn("Test Partner", content)
+ self.assertIn("test@example.com", content)
+ self.assertIn("+1234567890", content)
+ self.assertIn("United States", content)
+ self.assertIn("00000100+", content) # Company monetary format
+ self.assertIn("0000150050+", content) # Credit limit with sign
+
+ def test_postprocess_text_replacement_elements(self):
+ """Test text post-processing with replacement elements."""
+ report = self.demo_report
+
+ # Test content with various special elements
+ test_content = "Field1
Field2
Field3
Field4
Field5"
+ processed = report._postprocess_text(test_content)
+
+ expected = "Field1|Field2\tField3Field4\r\nField5"
+ self.assertEqual(processed, expected)
+
+ def test_postprocess_text_removes_line_breaks(self):
+ """Test that unwanted line breaks are removed."""
+ report = self.demo_report
+
+ # Test content with unwanted line breaks
+ test_content = "Line1\nLine2\r\nLine3\rLine4
Line5"
+ processed = report._postprocess_text(test_content)
+
+ # Unwanted line breaks should be removed, special elements preserved
+ self.assertNotIn("\n", processed)
+ self.assertNotIn("\r", processed)
+ self.assertIn("|", processed)
+
+ def test_postprocess_text_handles_bytes(self):
+ """Test that bytes input is handled correctly."""
+ report = self.demo_report
+
+ # Test bytes input
+ test_content = b"Field1
Field2
Field3"
+ processed = report._postprocess_text(test_content)
+
+ expected = "Field1|Field2\r\nField3"
+ self.assertEqual(processed, expected)
+
+ def test_replacement_elements_configuration(self):
+ """Test that replacement elements can be customized."""
+ report = self.demo_report
+
+ # Get default replacement elements
+ elements = report._get_replacement_elements()
+
+ # Check default elements
+ self.assertEqual(elements["CR"], "\r")
+ self.assertEqual(elements["LF"], "\n")
+ self.assertEqual(elements["CRLF"], "\r\n")
+ self.assertEqual(elements["TAB"], "\t")
+ self.assertEqual(elements["PIPE"], "|")
+ self.assertEqual(elements["SEMICOLON"], ";")
+
+ def test_case_insensitive_elements(self):
+ """Test that elements work case-insensitively."""
+ report = self.demo_report
+
+ # Test mixed case elements
+ test_content = "Field1
field2
Field3
field4
"
+ processed = report._postprocess_text(test_content)
+
+ expected = "Field1|field2|Field3field4"
+ self.assertEqual(processed, expected)
diff --git a/report_qweb_text_control/views/ir_actions_report_views.xml b/report_qweb_text_control/views/ir_actions_report_views.xml
new file mode 100644
index 0000000000..527581f9f2
--- /dev/null
+++ b/report_qweb_text_control/views/ir_actions_report_views.xml
@@ -0,0 +1,26 @@
+
+
+
+ ir.actions.report.form.inherit
+ ir.actions.report
+
+
+
+
+
+
+
+ Enable whitespace control and field formatting functions (A, N, M, D, T, DT) for text reports.
+
+
+
+
+
+
+
diff --git a/setup/report_qweb_text_control/odoo/addons/report_qweb_text_control b/setup/report_qweb_text_control/odoo/addons/report_qweb_text_control
new file mode 120000
index 0000000000..105c93d1e8
--- /dev/null
+++ b/setup/report_qweb_text_control/odoo/addons/report_qweb_text_control
@@ -0,0 +1 @@
+../../../../report_qweb_text_control
\ No newline at end of file
diff --git a/setup/report_qweb_text_control/setup.py b/setup/report_qweb_text_control/setup.py
new file mode 100644
index 0000000000..28c57bb640
--- /dev/null
+++ b/setup/report_qweb_text_control/setup.py
@@ -0,0 +1,6 @@
+import setuptools
+
+setuptools.setup(
+ setup_requires=['setuptools-odoo'],
+ odoo_addon=True,
+)