Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Superscript, Subscript and Fractional positioning #520

Merged
merged 10 commits into from
Sep 8, 2022
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- Added support for subscript, superscript, nominator and denominator char positioning as well as \<sub\> and \<sup\> HTML tags, thanks to @gmischler
- Added support for subscript, superscript, nominator and denominator char positioning as well as \<sub\> and \<sup\> HTML tags, thanks to @gmischler: [link to documentation](https://pyfpdf.github.io/fpdf2/TextStyling.html#subscript-superscript-and-fractional-numbers)

- [`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.
gmischler marked this conversation as resolved.
Show resolved Hide resolved
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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this feature work in in a .local_context()?

with pdf.local_context(char_vpos="SUP"):
    pdf.write(...)

I don't think it will currently, but do you think it could be useful?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the values are on the context stack, it will of course work within a local context as expected. It should also work when the values are supplied as arguments like in your example (covered by the **kwargs catch-all).
I'll check that to make sure and also add them to the docstring of .local_context().

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I had to explicitly enable "char_vpos". I think the various lift and scale parameters are too much to handle in local_context(), though. Anyone who changes those will likely do so globally anyway.


```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):
Lucas-C marked this conversation as resolved.
Show resolved Hide resolved
"""
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