Skip to content

Commit

Permalink
Support Superscript, Subscript and Fractional positioning (#520)
Browse files Browse the repository at this point in the history
  • Loading branch information
gmischler authored Sep 8, 2022
1 parent 356de1a commit dbc1114
Show file tree
Hide file tree
Showing 24 changed files with 466 additions and 66 deletions.
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,22 @@ This can also be enabled programmatically with `warnings.simplefilter('default',
- `fpdf2` now uses [fontTools](https://fonttools.readthedocs.io/en/latest/) to read and embed fonts in the PDF, thanks to @gmischler and @RedShy

### Fixed
- Text following a HTML heading can't overlap with that heading anymore, thanks to @gmischler
- `arc()` not longer renders artefacts at intersection point, thanks to @Jmillan-Dev; [#488](https://github.com/PyFPDF/fpdf2/issues/488)
- [`write_html()`](https://pyfpdf.github.io/fpdf2/HTML.html):
* `<em>` & `<strong>` HTML tags are now properly supported - they were ignored previously; [#498](https://github.com/PyFPDF/fpdf2/issues/498)
* `bgcolor` is not properly support in `<table>` tags; [#512](https://github.com/PyFPDF/fpdf2/issues/512)
- the `CreationDate` of PDFs & embedded files now includes the system timezone

### Added
- Added support for subscript, superscript, nominator and denominator char positioning as well as \<sub\> and \<sup\> HTML tags, thanks to @gmischler
- [`set_page_background()`](https://pyfpdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.set_page_background): new method added by @semaeostomea: [link to documentation](https://pyfpdf.github.io/fpdf2/PageFormatAndOrientation.html#per-page-format-orientation-and-background)
- [`embed_file()`](https://pyfpdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.embed_file) & [`file_attachment_annotation()`](https://pyfpdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.file_attachment_annotation): new methods to add file attachments - [link to documentation](https://pyfpdf.github.io/fpdf2/FileAttachments.html)
- A new method [`set_char_spacing()`](https://pyfpdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.set_char_spacing) allows to increase the spacing between individual characters, thanks to @gmischler: [link to documentation](https://pyfpdf.github.io/fpdf2/TextStyling.html)
- workaround by @semaeostomea to support arabic and right-to-left scripts: [link to documentation](https://pyfpdf.github.io/fpdf2/Unicode.html#right-to-left-arabic-script-workaround)
- documentation on shapes styling: [link to documentation](https://pyfpdf.github.io/fpdf2/Shapes.html#path-styling)
- documentation on sharing the images cache among FPDF instances: [link to documentation](https://pyfpdf.github.io/fpdf2/Images.html#sharing-the-image-cache-among-fpdf-instances)
### Changed
- HTML headings are now rendered with an additional leading of 20% the font size above and below them.

## [2.5.6] - 2022-08-16
### Added
Expand Down
1 change: 1 addition & 0 deletions docs/HTML.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ pdf.output("html.pdf")
* `<a>`: links (and `href` attribute)
* `<img>`: images (and `src`, `width`, `height` attributes)
* `<ol>`, `<ul>`, `<li>`: ordered, unordered and list items (can be nested)
* `<sup>`, `<sub>`: superscript and subscript text
* `<table>`: (and `border`, `width` attributes)
+ `<thead>`: header (opens each page)
+ `<tfoot>`: footer (closes each page)
Expand Down
63 changes: 63 additions & 0 deletions docs/TextStyling.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,69 @@ pdf.multi_cell(w=150, txt=LOREM_IPSUM[:200], new_x="LEFT", fill=True)
```
![](char_spacing.png)


## Subscript, Superscript, and Fractional Numbers

The class attribute `.char_vpos` controls special vertical positioning modes for text:

* "LINE" - normal line text (default)
* "SUP" - superscript (exponent)
* "SUB" - subscript (index)
* "NOM" - nominator of a fraction with "/"
* "DENOM" - denominator of a fraction with "/"

For each positioning mode there are two parameters that can be configured.
The defaults have been set to result in a decent layout with most fonts, and are given in parens.

The size multiplier for the font size:

* `.sup_scale` (0.7)
* `.sub_scale` (0.7)
* `.nom_scale` (0.75)
* `.denom_scale` (0.75)

The lift is given as fraction of the unscaled font size and indicates how much the glyph gets lifted above the base line (negative for below):

* `.sup_lift` (0.4)
* `.sub_lift` (-0.15)
* `.nom_lift` (0.2)
* `.denom_lift` (0.0)

**Limitations:** The individual glyphs will be scaled down as configured. This is not typographically correct, as it will also reduce the stroke width, making them look lighter than the normal text.
Unicode fonts may include characters in the [subscripts and superscripts range](https://en.wikipedia.org/wiki/Unicode_subscripts_and_superscripts). In a high quality font, those glyphs will be smaller than the normal ones, but have a proportionally stronger stroke width in order to maintain the same visual density. If available in good quality, using Characters from this range is preferred and will look better. Unfortunately, many fonts either don't (fully) cover this range, or the glyphs are of unsatisfactory quality. In those cases, this feature of `fpdf2` offers a reliable workaround with suboptimal but consistent output quality.

Practical use is essentially limited to `.write()` and `html_write()`.
The feature does technically work with `.cell()` and `.multi_cell`, but is of limited usefulness there, since you can't change font properties in the middle of a line (there is no markdown support). It currently gets completely ignored by `.text()`.

The example shows the most common use cases:

```python
pdf = fpdf.FPDF()
pdf.add_page()
pdf.set_font("Helvetica", "", 20)
pdf.write(txt="2")
pdf.char_vpos = "SUP"
pdf.write(txt="56")
pdf.char_vpos = "LINE"
pdf.write(txt=" more line text")
pdf.char_vpos = "SUB"
pdf.write(txt="(idx)")
pdf.char_vpos = "LINE"
pdf.write(txt=" end")
pdf.ln()
pdf.write(txt="1234 + ")
pdf.char_vpos = "NOM"
pdf.write(txt="5")
pdf.char_vpos = "LINE"
pdf.write(txt="/")
pdf.char_vpos = "DENOM"
pdf.write(txt="16")
pdf.char_vpos = "LINE"
pdf.write(txt=" + 987 = x")
```
![](char_vpos.png)


## .text_mode ##

The PDF spec defines several text modes:
Expand Down
Binary file added docs/char_vpos.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions fpdf/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,24 @@ def coerce(cls, value):
raise TypeError(f"{value} cannot convert to a {cls.__name__}")


class CharVPos(CoerciveEnum):
"Defines the vertical position of text relative to the line."
SUP = intern("SUP")
"Superscript"

SUB = intern("SUB")
"Subscript"

NOM = intern("NOM")
"Nominator of a fraction"

DENOM = intern("DENOM")
"Denominator of a fraction"

LINE = intern("LINE")
"Default line position"


class Align(CoerciveEnum):
"Defines how to render text in a cell"

Expand Down
18 changes: 14 additions & 4 deletions fpdf/fpdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class Image:
YPos,
Corner,
FontDescriptorFlags,
CharVPos,
)
from .errors import FPDFException, FPDFPageFormatException, FPDFUnicodeEncodingException
from .fonts import fpdf_charwidths
Expand Down Expand Up @@ -2555,6 +2556,7 @@ def local_context(
text_color
text_mode
underline
char_vpos
Args:
**kwargs: key-values settings to set at the beggining of this context.
Expand All @@ -2577,7 +2579,7 @@ def local_context(
setattr(gs, key, value)
if key == "blend_mode":
self._set_min_pdf_version("1.4")
elif key in ("font_stretching", "text_mode", "underline"):
elif key in ("font_stretching", "text_mode", "underline", "char_vpos"):
setattr(self, key, value)
else:
raise ValueError(f"Unsupported setting: {key}")
Expand Down Expand Up @@ -2889,6 +2891,7 @@ def _render_styled_text_line(
s_width, underlines = 0, []
# We try to avoid modifying global settings for temporary changes.
current_ws = frag_ws = 0.0
current_char_vpos = CharVPos.LINE
current_font = self.current_font
current_text_mode = self.text_mode
current_font_stretching = self.font_stretching
Expand Down Expand Up @@ -2932,9 +2935,14 @@ def _render_styled_text_line(
if current_char_spacing != frag.char_spacing:
current_char_spacing = frag.char_spacing
sl.append(f"{frag.char_spacing:.2f} Tc")
if current_font != frag.font:
if current_font != frag.font or current_char_vpos != frag.char_vpos:
if current_char_vpos != frag.char_vpos:
current_char_vpos = frag.char_vpos
current_font = frag.font
sl.append(f"/F{frag.font['i']} {frag.font_size_pt:.2f} Tf")
lift = frag.lift
if lift != 0.0:
sl.append(f"{lift:.2f} Ts")
if (
frag.text_mode != TextMode.FILL
or frag.text_mode != current_text_mode
Expand Down Expand Up @@ -3009,10 +3017,12 @@ def _render_styled_text_line(
)

if sl:
# If any PDF settings have been left modified, wrap the line in a local context.
# If any PDF settings have been left modified, wrap the line
# in a local context.
# pylint: disable=too-many-boolean-expressions
if (
current_ws != 0.0
or current_char_vpos != CharVPos.LINE
or current_font != self.current_font
or current_text_mode != self.text_mode
or self.fill_color != self.text_color
Expand All @@ -3022,8 +3032,8 @@ def _render_styled_text_line(
s = f"q {' '.join(sl)} Q"
else:
s = " ".join(sl)
self._out(s)
# pylint: enable=too-many-boolean-expressions
self._out(s)
self.lasth = h

# XPos.LEFT -> self.x stays the same
Expand Down
155 changes: 154 additions & 1 deletion fpdf/graphics_state.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from .drawing import DeviceGray
from .enums import TextMode
from .enums import TextMode, CharVPos


class GraphicsStateMixin:
Expand Down Expand Up @@ -34,6 +34,15 @@ def __init__(self, *args, **kwargs):
dash_pattern=dict(dash=0, gap=0, phase=0),
line_width=0,
text_mode=TextMode.FILL,
char_vpos=CharVPos.LINE,
sub_scale=0.7,
sup_scale=0.7,
nom_scale=0.75,
denom_scale=0.75,
sub_lift=-0.15,
sup_lift=0.4,
nom_lift=0.2,
denom_lift=0.0,
),
]
super().__init__(*args, **kwargs)
Expand Down Expand Up @@ -158,3 +167,147 @@ def text_mode(self):
@text_mode.setter
def text_mode(self, v):
self.__statestack[-1]["text_mode"] = TextMode.coerce(v)

@property
def char_vpos(self):
"""
Return vertical character position relative to line.
([docs](../TextStyling.html#subscript-superscript-and-fractional-numbers))
"""
return self.__statestack[-1]["char_vpos"]

@char_vpos.setter
def char_vpos(self, v):
"""
Set vertical character position relative to line.
([docs](../TextStyling.html#subscript-superscript-and-fractional-numbers))
"""
self.__statestack[-1]["char_vpos"] = CharVPos.coerce(v)

@property
def sub_scale(self):
"""
Return scale factor for subscript text.
([docs](../TextStyling.html#subscript-superscript-and-fractional-numbers))
"""
return self.__statestack[-1]["sub_scale"]

@sub_scale.setter
def sub_scale(self, v):
"""
Set scale factor for subscript text.
([docs](../TextStyling.html#subscript-superscript-and-fractional-numbers))
"""
self.__statestack[-1]["sub_scale"] = float(v)

@property
def sup_scale(self):
"""
Return scale factor for superscript text.
([docs](../TextStyling.html#subscript-superscript-and-fractional-numbers))
"""
return self.__statestack[-1]["sup_scale"]

@sup_scale.setter
def sup_scale(self, v):
"""
Set scale factor for superscript text.
([docs](../TextStyling.html#subscript-superscript-and-fractional-numbers))
"""
self.__statestack[-1]["sup_scale"] = float(v)

@property
def nom_scale(self):
"""
Return scale factor for nominator text.
([docs](../TextStyling.html#subscript-superscript-and-fractional-numbers))
"""
return self.__statestack[-1]["nom_scale"]

@nom_scale.setter
def nom_scale(self, v):
"""
Set scale factor for nominator text.
([docs](../TextStyling.html#subscript-superscript-and-fractional-numbers))
"""
self.__statestack[-1]["nom_scale"] = float(v)

@property
def denom_scale(self):
"""
Return scale factor for denominator text.
([docs](../TextStyling.html#subscript-superscript-and-fractional-numbers))
"""
return self.__statestack[-1]["denom_scale"]

@denom_scale.setter
def denom_scale(self, v):
"""
Set scale factor for denominator text.
([docs](../TextStyling.html#subscript-superscript-and-fractional-numbers))
"""
self.__statestack[-1]["denom_scale"] = float(v)

@property
def sub_lift(self):
"""
Return lift factor for subscript text.
([docs](../TextStyling.html#subscript-superscript-and-fractional-numbers))
"""
return self.__statestack[-1]["sub_lift"]

@sub_lift.setter
def sub_lift(self, v):
"""
Set lift factor for subscript text.
([docs](../TextStyling.html#subscript-superscript-and-fractional-numbers))
"""
self.__statestack[-1]["sub_lift"] = float(v)

@property
def sup_lift(self):
"""
Return lift factor for superscript text.
([docs](../TextStyling.html#subscript-superscript-and-fractional-numbers))
"""
return self.__statestack[-1]["sup_lift"]

@sup_lift.setter
def sup_lift(self, v):
"""
Set lift factor for superscript text.
([docs](../TextStyling.html#subscript-superscript-and-fractional-numbers))
"""
self.__statestack[-1]["sup_lift"] = float(v)

@property
def nom_lift(self):
"""
Return lift factor for nominator text.
([docs](../TextStyling.html#subscript-superscript-and-fractional-numbers))
"""
return self.__statestack[-1]["nom_lift"]

@nom_lift.setter
def nom_lift(self, v):
"""
Set lift factor for nominator text.
([docs](../TextStyling.html#subscript-superscript-and-fractional-numbers))
"""
self.__statestack[-1]["nom_lift"] = float(v)

@property
def denom_lift(self):
"""
Return lift factor for denominator text.
([docs](../TextStyling.html#subscript-superscript-and-fractional-numbers))
"""
return self.__statestack[-1]["denom_lift"]

@denom_lift.setter
def denom_lift(self, v):
"""
Set lift factor for denominator text.
([docs](../TextStyling.html#subscript-superscript-and-fractional-numbers))
"""
self.__statestack[-1]["denom_lift"] = float(v)
Loading

0 comments on commit dbc1114

Please sign in to comment.