Skip to content

XLSX writer: XML escaping missing in font names and hyperlink targets (breaks openpyxl/lxml) #1

Description

@matalab

XLSX writer: XML escaping missing in font names and hyperlink targets (breaks openpyxl/lxml)

Summary

The OOXML XLSX writer in xlsxooxml.pas does not XML-escape two categories of strings when writing them as XML attribute values:

  1. Font names in xl/styles.xml — quotes in font names are not escaped, producing invalid XML
  2. Hyperlink targets in *.rels files — ampersands in URLs are not escaped, producing invalid XML

Both issues cause lxml.etree.XMLSyntaxError: attributes construct error when the generated .xlsx file is loaded by openpyxl (Python) or any other strict XML parser.

Environment

  • fpspreadsheet version: as shipped with CodeTyphon (based on lazarus-ccr SVN, last synced 2026-03-15)
  • File: source/common/xlsxooxml.pas
  • OS: Linux x86_64
  • Compiler: Free Pascal 3.x (CodeTyphon distribution)

Bug #1: Unescaped quotes in font names

Location

xlsxooxml.pas, procedure TsSpreadOOXMLWriter.WriteFont, line ~5783:

if AFont.FontName <> '' then
  s := s + Format('<%s val="%s" />', [NAME_TAG[UseInStyleNode], AFont.FontName]);

Problem

AFont.FontName is inserted directly into an XML attribute value without escaping. If the font name contains a double quote character ("), the resulting XML is malformed:

<!-- Produced (invalid): -->
<font><name val=""Google Sans"" /></font>

<!-- Expected (valid): -->
<font><name val="&quot;Google Sans&quot;" /></font>

Fix

Use the existing UTF8TextToXMLText() function (from fpsxmlcommon, already in the uses clause):

if AFont.FontName <> '' then
  s := s + Format('<%s val="%s" />', [NAME_TAG[UseInStyleNode], UTF8TextToXMLText(AFont.FontName)]);

Bug #2: Unescaped ampersands in hyperlink targets

Location

xlsxooxml.pas, procedure TsSpreadOOXMLWriter.WriteWorksheetRels, line ~7372:

s := Format('Id="rId%d" Target="%s" TargetMode="External" Type="%s"',
  [rId_Hyperlink, target, SCHEMAS_HYPERLINK]);

Problem

The target string (a URL from a hyperlink) is inserted directly into the XML attribute without escaping ampersands. URLs with query parameters (e.g., ?param=1&param=2) produce invalid XML:

<!-- Produced (invalid): -->
<Relationship Id="rId1" Target="https://example.com/page?a=1&b=2" TargetMode="External" Type="..." />

<!-- Expected (valid): -->
<Relationship Id="rId1" Target="https://example.com/page?a=1&amp;b=2" TargetMode="External" Type="..." />

Fix

s := Format('Id="rId%d" Target="%s" TargetMode="External" Type="%s"',
  [rId_Hyperlink, UTF8TextToXMLText(target), SCHEMAS_HYPERLINK]);

Existing utility function

Both fixes use UTF8TextToXMLText() from the fpsxmlcommon unit, which is already in the uses clause of xlsxooxml.pas. This function correctly escapes &&amp;, <&lt;, >&gt;, "&quot;, '&apos;, and avoids double-escaping existing entities.

Reproduction

  1. Create a workbook with a font whose name contains a double quote (e.g., "Google Sans")
  2. Add a hyperlink with a URL containing & (e.g., https://example.com/page?a=1&b=2)
  3. Save as .xlsx
  4. Attempt to load with openpyxl.load_workbook("file.xlsx")

Result: lxml.etree.XMLSyntaxError: attributes construct error

Workaround (Python)

import zipfile, shutil, os, re

def fix_spready_xlsx(path):
    tmp = path + ".tmp"
    shutil.copy2(path, tmp)
    with zipfile.ZipFile(tmp, 'r') as zin:
        with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as zout:
            for item in zin.namelist():
                data = zin.read(item)
                if 'styles' in item or '.rels' in item:
                    text = data.decode('utf-8', errors='replace')
                    text = text.replace('""', '"')
                    text = re.sub(r'&(?!amp;|lt;|gt;|quot;|apos;|#\d+;|#x[0-9a-fA-F]+;)', '&amp;', text)
                    data = text.encode('utf-8')
                zout.writestr(item, data)
    os.remove(tmp)

Impact

High — any application that combines fpspreadsheet-generated .xlsx files with strict XML parsers (openpyxl, lxml, .NET Open XML SDK) will fail to load them. This affects data pipelines, automated processing, and interoperability with the Python data ecosystem.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions