diff --git a/docs/images/img-freetext.jpg b/docs/images/img-freetext1.jpg similarity index 100% rename from docs/images/img-freetext.jpg rename to docs/images/img-freetext1.jpg diff --git a/docs/images/img-freetext2.jpg b/docs/images/img-freetext2.jpg new file mode 100644 index 000000000..36fe4f47c Binary files /dev/null and b/docs/images/img-freetext2.jpg differ diff --git a/docs/page.rst b/docs/page.rst index 10dd1cef5..6a968c735 100644 --- a/docs/page.rst +++ b/docs/page.rst @@ -180,44 +180,63 @@ In a nutshell, this is what you can do with PyMuPDF: :returns: the created annotation. Stroke color yellow = (1, 1, 0), no fill color support. .. index:: - pair: color; add_freetext_annot - pair: fontname; add_freetext_annot - pair: fontsize; add_freetext_annot pair: rect; add_freetext_annot - pair: rotate; add_freetext_annot - pair: align; add_freetext_annot + pair: fontsize; add_freetext_annot + pair: fontname; add_freetext_annot pair: text_color; add_freetext_annot - pair: border_color; add_freetext_annot pair: fill_color; add_freetext_annot + pair: border_width; add_freetext_annot + pair: dashes; add_freetext_annot + pair: callout; add_freetext_annot + pair: line_end; add_freetext_annot + pair: opacity; add_freetext_annot + pair: align; add_freetext_annot + pair: rotate; add_freetext_annot + pair: richtext; add_freetext_annot + pair: style; add_freetext_annot + + .. method:: add_freetext_annot(rect, text, *, fontsize=11, fontname="helv", text_color=0, fill_color=None, border_width=0, dashes=None, callout=None, line_end=PDF_ANNOT_LE_OPEN_ARROW, opacity=1, align=TEXT_ALIGN_LEFT, rotate=0, richtext=False, style=None) - .. method:: add_freetext_annot(rect, text, fontsize=12, fontname="helv", border_color=None, text_color=0, fill_color=1, rotate=0, align=TEXT_ALIGN_LEFT) + PDF only: Add text in a given rectangle. Optionally, the appearance of a "callout" shape can be requested by specifying two or three point-like objects -- see below. - PDF only: Add text in a given rectangle. + :arg rect_like rect: the rectangle into which the text should be inserted. Text is automatically wrapped to a new line at box width. Text portions not fitting into the rectangle will be invisible without warning. - :arg rect_like rect: the rectangle into which the text should be inserted. Text is automatically wrapped to a new line at box width. Lines not fitting into the box will be invisible. + :arg str text: the text. May contain any mixture of Latin, Greek, Cyrillic, Chinese, Japanese and Korean characters. If `richtext=True` (see below), the string is interpreted as HTML syntax. This adds a plethora of ways for attractive effects. - :arg str text: the text. May contain any mixture of Latin, Greek, Cyrillic, Chinese, Japanese and Korean characters. The respective required font is automatically determined. (New in v1.17.0) - :arg float fontsize: the :data:`fontsize`. Default is 12. - :arg str fontname: the font name. Default is "Helv". - Accepted alternatives are "Cour", "TiRo", "ZaDb" and "Symb". - The name may be abbreviated to the first two characters, like "Co" for "Cour". - Lower case is also accepted. - Bold or italic variants of the fonts are **not accepted** (changed in v1.16.0). - A user-contributed script provides a circumvention for this restriction -- see section *Using Buttons and JavaScript* in chapter :ref:`FAQ`. - The actual font to use is now determined on a by-character level, and all required fonts (or sub-fonts) are automatically included. - Therefore, you should rarely ever need to care about this parameter and let it default (except you insist on a serifed font for your non-CJK text parts). (New in v1.17.0) + :arg float fontsize: the :data:`fontsize`. Default is 11. Ignored if `richtext=True`. + + :arg str fontname: The font name. Default is "Helv". Ignored if `richtext=True`, otherwise the following **restritions apply:** - :arg sequence,float text_color: the text color. Default is black. (New in v1.16.0) + * Accepted alternatives are "Helv" (Helvetica), "Cour" (Courier), "TiRo" (Timnes-Roman), "ZaDb" (ZapfDingBats) and "Symb" (Symbol). The name may be abbreviated to the first two characters, like "Co" for "Cour", lower case accepted. - :arg sequence,float fill_color: the fill color. Default is white. (New in v1.16.0) - :arg sequence,float text_color: the text color. Default is black. - :arg sequence,float border_color: the border color. Default is `None`. (New in v1.19.6) - :arg int align: text alignment, one of TEXT_ALIGN_LEFT, TEXT_ALIGN_CENTER, TEXT_ALIGN_RIGHT - justify is **not supported**. (New in v1.17.0) + * Bold or italic variants of the fonts are **not supported.** + + :arg list,tuple,float text_color: the text color. Default is black. Ignored if `richtext=True`. - :arg int rotate: the text orientation. Accepted values are 0, 90, 270, invalid entries are set to zero. + :arg list,tuple,float fill_color: the fill color. This is used for ``rect`` and the end point of the callout lines when applicable. Default is ``None``. + + :arg list,tuple,float border_color: This parameter only has an effect if `richtext=True`. Otherwise, ``text_color`` is used. + + :arg float border_width: the width of border and ``callout`` lines. Default is 0 (no border), in which case callout lines may still appear with some hairline width, depending on the PDF viewer used. + + :arg list,tuple dashes: a list of floats specifying how border and callout lines should be dashed. Default is ``None``. + + :arg list,tuple callout: a list / tuple of two or three :data:`point_like` objects, which will be interpreted as end point [, knee point] and start point (in this sequence) of up to two line segments, converting this annotation into a call-out shape. + + :arg int line_end: the line end symbol of the call-out line. It is drawn at the first point specified in the `callout` list. Default is an open arrow. For possible values see :ref:`AnnotationLineEnds`. + + :arg float opacity: a float `0 <= opacity < 1` turning the annotation transparent. Default is no transparency. + + :arg int align: text alignment, one of TEXT_ALIGN_LEFT, TEXT_ALIGN_CENTER, TEXT_ALIGN_RIGHT - justify is **not supported**. Ignored if `richtext=True`. + + :arg int rotate: the text orientation. Accepted values are integer multiples of 90°. Invalid entries receive a rotation of 0. + + :arg bool richtext: treat ``text`` as HTML syntax. This allows to achieve **bold**, *italic*, arbitrary text colors, font sizes, text alignment including justify and more - as far as HTML and styling instructions support this. This is similar to what happens in :meth:`Page.insert_htmlbox`. The base library will for example pull in required fonts if it encounters characters not contained in the standard ones. Some parameters are ignored if this option is set, as mentioned above. Default is ``False``. + + :arg str style: supply optional HTML styling information in CSS syntax. Ignored if `richtext=False`. :rtype: :ref:`Annot` - :returns: the created annotation. Color properties **can only be changed** using special parameters of :meth:`Annot.update`. There, you can also set a border color different from the text color. + :returns: the created annotation. |history_begin| diff --git a/docs/recipes-annotations.rst b/docs/recipes-annotations.rst index 8cc6e595b..da5340e42 100644 --- a/docs/recipes-annotations.rst +++ b/docs/recipes-annotations.rst @@ -34,16 +34,25 @@ This script should lead to the following output: How to Use FreeText ~~~~~~~~~~~~~~~~~~~~~ -This script shows a couple of ways to deal with 'FreeText' annotations: +This script shows a couple of basic ways to deal with 'FreeText' annotations: -.. literalinclude:: samples/annotations-freetext.py +.. literalinclude:: samples/annotations-freetext1.py +The result looks like this: + +.. image:: images/img-freetext1.* + :scale: 80 + +Here is an example for using rich text and call-out lines: + +.. literalinclude:: samples/annotations-freetext2.py The result looks like this: -.. image:: images/img-freetext.* +.. image:: images/img-freetext2.* :scale: 80 + ------------------------------ diff --git a/docs/samples/annotations-freetext.py b/docs/samples/annotations-freetext1.py similarity index 80% rename from docs/samples/annotations-freetext.py rename to docs/samples/annotations-freetext1.py index 030f5b062..c98c23690 100644 --- a/docs/samples/annotations-freetext.py +++ b/docs/samples/annotations-freetext1.py @@ -2,19 +2,19 @@ import pymupdf # some colors -blue = (0,0,1) -green = (0,1,0) -red = (1,0,0) -gold = (1,1,0) +blue = (0, 0, 1) +green = (0, 1, 0) +red = (1, 0, 0) +gold = (1, 1, 0) # a new PDF with 1 page doc = pymupdf.open() page = doc.new_page() # 3 rectangles, same size, above each other -r1 = pymupdf.Rect(100,100,200,150) -r2 = r1 + (0,75,0,75) -r3 = r2 + (0,75,0,75) +r1 = pymupdf.Rect(100, 100, 200, 150) +r2 = r1 + (0, 75, 0, 75) +r3 = r2 + (0, 75, 0, 75) # the text, Latin alphabet t = "¡Un pequeño texto para practicar!" diff --git a/docs/samples/annotations-freetext2.py b/docs/samples/annotations-freetext2.py new file mode 100644 index 000000000..e48a3ae03 --- /dev/null +++ b/docs/samples/annotations-freetext2.py @@ -0,0 +1,50 @@ +import pymupdf + +"""Use rich text for FreeText annotations""" + +# define an overall styling +ds = """font-size: 11pt; font-family: sans-serif;""" + +# some special characters +bullet = chr(0x2610) + chr(0x2611) + chr(0x2612) + +# the annotation text with HTML and styling syntax +text = f"""

+PyMuPDF འདི་ ཡིག་ཆ་བཀྲམ་སྤེལ་གྱི་དོན་ལུ་ པའི་ཐོན་ཐུམ་སྒྲིལ་དྲག་ཤོས་དང་མགྱོགས་ཤོས་ཅིག་ཨིན། +Here is some bold and italic text, followed by bold-italic. Text-based check boxes: {bullet}. +

""" + +# here are some colors +gold = (1, 1, 0) +green = (0, 1, 0) + +# new/empty PDF +doc = pymupdf.open() + +# make a page in ISO-A4 format +page = doc.new_page() + +# text goes into this: +rect = pymupdf.Rect(100, 100, 350, 200) + +# define some points for callout lines +p2 = rect.tr + (50, 30) +p3 = p2 + (0, 30) + +# define the annotation +annot = page.add_freetext_annot( + rect, + text, + fill_color=gold, # fill color + opacity=1, # non-transparent + rotate=0, # no rotation + border_width=1, # border and callout line width + dashes=None, # no dashing + richtext=True, # this is rich text + style=ds, # my styling default + callout=(p3, p2, rect.tr), # define end, knee, start points + line_end=pymupdf.PDF_ANNOT_LE_OPEN_ARROW, # symbol shown at p3 + border_color=green, +) + +doc.save(__file__.replace(".py", ".pdf"), pretty=True) diff --git a/src/__init__.py b/src/__init__.py index e642c6491..bee04f7c9 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -669,22 +669,23 @@ def _update_appearance(self, opacity=-1, blend_mode=None, fill_color=None, rotat insert_rot = 0 if insert_rot: - mupdf.pdf_dict_put_int( annot_obj, PDF_NAME('Rotate'), rotate) + mupdf.pdf_dict_put_int(annot_obj, PDF_NAME('Rotate'), rotate) - mupdf.pdf_dirty_annot( annot) - mupdf.pdf_update_annot( annot) # let MuPDF update - pdf.resynth_required = 0 # insert fill color if type_ == mupdf.PDF_ANNOT_FREE_TEXT: if nfcol > 0: - mupdf.pdf_set_annot_color( annot, fcol[:nfcol]) + mupdf.pdf_set_annot_color(annot, fcol[:nfcol]) elif nfcol > 0: - col = mupdf.pdf_new_array( page.doc(), nfcol) + col = mupdf.pdf_new_array(page.doc(), nfcol) for i in range( nfcol): - mupdf.pdf_array_push_real( col, fcol[i]) - mupdf.pdf_dict_put( annot_obj, PDF_NAME('IC'), col) + mupdf.pdf_array_push_real(col, fcol[i]) + mupdf.pdf_dict_put(annot_obj, PDF_NAME('IC'), col) + mupdf.pdf_dirty_annot(annot) + mupdf.pdf_update_annot(annot) # let MuPDF update + pdf.resynth_required = 0 except Exception as e: - if g_exceptions_verbose: exception_info() + if g_exceptions_verbose: + exception_info() message( f'cannot update annot: {e}') raise @@ -1587,42 +1588,27 @@ def color_string(cs, code): if not hasattr(opacity, "__float__"): opacity = self.opacity - if 0 <= opacity < 1 or blend_mode is not None: + if 0 <= opacity < 1 or blend_mode: opa_code = "/H gs\n" # then we must reference this 'gs' else: opa_code = "" if annot_type == mupdf.PDF_ANNOT_FREE_TEXT: - CheckColor(border_color) CheckColor(text_color) CheckColor(fill_color) tcol, fname, fsize = TOOLS._parse_da(self) # read and update default appearance as necessary - update_default_appearance = False if fsize <= 0: fsize = 12 - update_default_appearance = True - if text_color is not None: + if text_color: tcol = text_color - update_default_appearance = True - if fontname is not None: + if fontname: fname = fontname - update_default_appearance = True if fontsize > 0: fsize = fontsize - update_default_appearance = True - - if update_default_appearance: - da_str = "" - if len(tcol) == 3: - fmt = "{:g} {:g} {:g} rg /{f:s} {s:g} Tf" - elif len(tcol) == 1: - fmt = "{:g} g /{f:s} {s:g} Tf" - elif len(tcol) == 4: - fmt = "{:g} {:g} {:g} {:g} k /{f:s} {s:g} Tf" - da_str = fmt.format(*tcol, f=fname, s=fsize) - TOOLS._update_da(self, da_str) + JM_make_annot_DA(self, len(tcol), tcol, fname, fsize) + blend_mode = None # not supported for free text annotations! #------------------------------------------------------------------ # now invoke MuPDF to update the annot appearance @@ -1636,6 +1622,13 @@ def color_string(cs, code): if val is False: raise RuntimeError("Error updating annotation.") + if annot_type == mupdf.PDF_ANNOT_FREE_TEXT: + # in absence of previous opacity, we may need to modify the AP + ap = self._getAP() + if 0 <= opacity < 1 and not ap.startswith(b"/H gs"): + self._setAP(b"/H gs\n" + ap) + return + bfill = color_string(fill, "f") bstroke = color_string(stroke, "c") @@ -1683,36 +1676,6 @@ def color_string(cs, code): ap = b"\n".join(ap_tab) - if annot_type == mupdf.PDF_ANNOT_FREE_TEXT: - BT = ap.find(b"BT") - ET = ap.rfind(b"ET") + 2 - ap = ap[BT:ET] - w, h = self.rect.width, self.rect.height - if rotate in (90, 270) or not (apnmat.b == apnmat.c == 0): - w, h = h, w - re = b"0 0 " + _format_g((w, h)).encode() + b" re" - ap = re + b"\nW\nn\n" + ap - ope = None - fill_string = color_string(fill, "f") - if fill_string: - ope = b"f" - stroke_string = color_string(border_color, "c") - if stroke_string and bwidth > 0: - ope = b"S" - bwidth = _format_g(bwidth).encode() + b" w\n" - else: - bwidth = stroke_string = b"" - if fill_string and stroke_string: - ope = b"B" - if ope is not None: - ap = bwidth + fill_string + stroke_string + re + b"\n" + ope + b"\n" + ap - - if dashes is not None: # handle dashes - ap = dashes + b"\n" + ap - dashes = None - - ap_updated = True - if annot_type in (mupdf.PDF_ANNOT_POLYGON, mupdf.PDF_ANNOT_POLY_LINE): ap = b"\n".join(ap_tab[:-1]) + b"\n" ap_updated = True @@ -7567,58 +7530,74 @@ def _add_freetext_annot( text_color=None, fill_color=None, border_color=None, + border_width=0, + dashes=None, + callout=None, + line_end=mupdf.PDF_ANNOT_LE_OPEN_ARROW, + opacity=1, align=0, rotate=0, + richtext=False, + style=None, ): + rc = f""" + + {text}""" page = self._pdf_page() + if border_color and not text_color: + text_color = border_color nfcol, fcol = JM_color_FromSequence(fill_color) ntcol, tcol = JM_color_FromSequence(text_color) r = JM_rect_from_py(rect) if mupdf.fz_is_infinite_rect(r) or mupdf.fz_is_empty_rect(r): raise ValueError( MSG_BAD_RECT) - annot = mupdf.pdf_create_annot( page, mupdf.PDF_ANNOT_FREE_TEXT) - annot_obj = mupdf.pdf_annot_obj( annot) - mupdf.pdf_set_annot_contents( annot, text) - mupdf.pdf_set_annot_rect( annot, r) - mupdf.pdf_dict_put_int( annot_obj, PDF_NAME('Rotate'), rotate) - mupdf.pdf_dict_put_int( annot_obj, PDF_NAME('Q'), align) + annot = mupdf.pdf_create_annot(page, mupdf.PDF_ANNOT_FREE_TEXT) + annot_obj = mupdf.pdf_annot_obj(annot) + + #insert text as 'contents' or 'RC' depending on 'richtext' + if not richtext: + mupdf.pdf_set_annot_contents(annot, text) + else: + mupdf.pdf_dict_put_text_string(annot_obj,PDF_NAME("RC"), rc) + if style: + mupdf.pdf_dict_put_text_string(annot_obj,PDF_NAME("DS"), style) + + mupdf.pdf_set_annot_rect(annot, r) + + while rotate < 0: + rotate += 360 + while rotate >= 360: + rotate -= 360 + if rotate != 0: + mupdf.pdf_dict_put_int(annot_obj, PDF_NAME('Rotate'), rotate) + + mupdf.pdf_set_annot_quadding(annot, align) if nfcol > 0: - mupdf.pdf_set_annot_color( annot, fcol[:nfcol]) + mupdf.pdf_set_annot_color(annot, fcol[:nfcol]) + + mupdf.pdf_set_annot_border_width(annot, border_width) + mupdf.pdf_set_annot_opacity(annot, opacity) + if dashes: + for d in dashes: + mupdf.pdf_add_annot_border_dash_item(annot, float(d)) + + # Insert callout information + if callout: + mupdf.pdf_dict_put(annot_obj, PDF_NAME("IT"), PDF_NAME("FreeTextCallout")) + mupdf.pdf_set_annot_callout_style(annot, line_end) + point_count = len(callout) + extra.JM_set_annot_callout_line(annot, tuple(callout), point_count) # insert the default appearance string - JM_make_annot_DA(annot, ntcol, tcol, fontname, fontsize) - mupdf.pdf_update_annot( annot) + if not richtext: + JM_make_annot_DA(annot, ntcol, tcol, fontname, fontsize) + + mupdf.pdf_update_annot(annot) JM_add_annot_id(annot, "A") val = Annot(annot) - - #%pythonappend _add_freetext_annot - ap = val._getAP() - BT = ap.find(b"BT") - ET = ap.rfind(b"ET") + 2 - ap = ap[BT:ET] - w = rect[2]-rect[0] - h = rect[3]-rect[1] - if rotate in (90, -90, 270): - w, h = h, w - re = f"0 0 {_format_g((w, h))} re".encode() - ap = re + b"\nW\nn\n" + ap - ope = None - bwidth = b"" - fill_string = ColorCode(fill_color, "f").encode() - if fill_string: - fill_string += b"\n" - ope = b"f" - stroke_string = ColorCode(border_color, "c").encode() - if stroke_string: - stroke_string += b"\n" - bwidth = b"1 w\n" - ope = b"S" - if fill_string and stroke_string: - ope = b"B" - if ope is not None: - ap = bwidth + fill_string + stroke_string + re + b"\n" + ope + b"\n" + ap - val._setAP(ap) return val def _add_ink_annot(self, list): @@ -8325,13 +8304,21 @@ def add_freetext_annot( self, rect: rect_like, text: str, + *, fontsize: float =11, fontname: OptStr =None, - border_color: OptSeq =None, text_color: OptSeq =None, fill_color: OptSeq =None, + border_color: OptSeq =None, + border_width: float =0, + dashes: OptSeq =None, + callout: OptSeq =None, + line_end: int=mupdf.PDF_ANNOT_LE_OPEN_ARROW, + opacity: float =1, align: int =0, - rotate: int =0 + rotate: int =0, + richtext=False, + style=None, ) -> Annot: """Add a 'FreeText' annotation.""" @@ -8342,11 +8329,18 @@ def add_freetext_annot( text, fontsize=fontsize, fontname=fontname, - border_color=border_color, text_color=text_color, fill_color=fill_color, + border_color=border_color, + border_width=border_width, + dashes=dashes, + callout=callout, + line_end=line_end, + opacity=opacity, align=align, rotate=rotate, + richtext=richtext, + style=style, ) finally: if old_rotation != 0: @@ -14836,7 +14830,7 @@ def JM_clear_pixmap_rect_with_value(dest, value, b): def JM_color_FromSequence(color): if isinstance(color, (int, float)): # maybe just a single float - color = color[0] + color = [color] if not isinstance( color, (list, tuple)): return -1, [] diff --git a/src/extra.i b/src/extra.i index 96a6ce1a4..1c1647f08 100644 --- a/src/extra.i +++ b/src/extra.i @@ -548,6 +548,7 @@ static std::vector< std::string> JM_get_annot_id_list(mupdf::PdfPage& page) return names; } + //------------------------------------------------------------------------ // Add a unique /NM key to an annotation or widget. // Append a number to 'stem' such that the result is a unique name. @@ -777,6 +778,22 @@ jm_init_item(PyObject* obj, Py_ssize_t idx, int* result) return 0; } +// TODO: ------------------------------------------------------------------ +// This is a temporary solution and should be replaced by a C++ extension: +// There is no way in Python specify an array of fz_point - as is required +// for function pdf_set_annot_callout_line(). +static void JM_set_annot_callout_line(mupdf::PdfAnnot& annot, PyObject *callout, int count) +{ + fz_point points[3]; + mupdf::FzPoint p; + for (int i = 0; i < count; i++) + { + p = JM_point_from_py(PyTuple_GetItem(callout, (Py_ssize_t) i)); + points[i] = fz_make_point(p.x, p.y); + } + mupdf::pdf_set_annot_callout_line(annot, points, count); +} + //---------------------------------------------------------------------------- // Return list of outline xref numbers. Recursive function. Arguments: @@ -4068,6 +4085,7 @@ int page_xref(mupdf::FzDocument& this_doc, int pno); void _newPage(mupdf::FzDocument& self, int pno=-1, float width=595, float height=842); void _newPage(mupdf::PdfDocument& self, int pno=-1, float width=595, float height=842); void JM_add_annot_id(mupdf::PdfAnnot& annot, const char* stem); +void JM_set_annot_callout_line(mupdf::PdfAnnot& annot, PyObject *callout, int count); std::vector< std::string> JM_get_annot_id_list(mupdf::PdfPage& page); mupdf::PdfAnnot _add_caret_annot(mupdf::PdfPage& self, mupdf::FzPoint& point); mupdf::PdfAnnot _add_caret_annot(mupdf::FzPage& self, mupdf::FzPoint& point); diff --git a/tests/test_annots.py b/tests/test_annots.py index 35e92c48b..efbdc4672 100644 --- a/tests/test_annots.py +++ b/tests/test_annots.py @@ -511,3 +511,85 @@ def test_4079(): print(f'{rms=}') # 2024-11-27 Expect current broken behaviour. assert rms == 0 + +def test_4254(): + """Ensure that both annotations are fully created + + We do this by asserting equal top-used colors in respective pixmaps. + """ + doc = pymupdf.open() + page = doc.new_page() + + rect = pymupdf.Rect(100, 100, 200, 150) + annot = page.add_freetext_annot(rect, "Test Annotation from minimal example") + annot.set_border(width=1, dashes=(3, 3)) + annot.set_opacity(0.5) + annot.set_colors(stroke=(1, 0, 0)) + annot.update() + + rect = pymupdf.Rect(200, 200, 400, 400) + annot2 = page.add_freetext_annot(rect, "Test Annotation from minimal example pt 2") + annot2.set_border(width=1, dashes=(3, 3)) + annot2.set_opacity(0.5) + annot2.set_colors(stroke=(1, 0, 0)) + annot2.update() + + # stores top color for each pixmap + top_colors = set() + for annot in page.annots(): + pix = annot.get_pixmap() + top_colors.add(pix.color_topusage()[1]) + + # only one color must exist + assert len(top_colors) == 1 + +def test_richtext(): + """Test creation of rich text FreeText annotations. + + We create the same annotation on different pages in different ways, + with and without using Annotation.update(), and then assert equality + of the respective images. + """ + ds = """font-size: 11pt; font-family: sans-serif;""" + bullet = chr(0x2610) + chr(0x2611) + chr(0x2612) + text = f"""

+ PyMuPDF འདི་ ཡིག་ཆ་བཀྲམ་སྤེལ་གྱི་དོན་ལུ་ པའི་ཐོན་ཐུམ་སྒྲིལ་དྲག་ཤོས་དང་མགྱོགས་ཤོས་ཅིག་ཨིན། + Here is some bold and italic text, followed by bold-italic. Text-based check boxes: {bullet}. +

""" + gold = (1, 1, 0) + doc = pymupdf.open() + + # First page. + page = doc.new_page() + rect = pymupdf.Rect(100, 100, 350, 200) + p2 = rect.tr + (50, 30) + p3 = p2 + (0, 30) + annot = page.add_freetext_annot( + rect, + text, + fill_color=gold, + opacity=0.5, + rotate=90, + border_width=1, + dashes=None, + richtext=True, + callout=(p3, p2, rect.tr), + ) + + pix1 = page.get_pixmap() + + # Second page. + # the annotation is created with minimal parameters, which are supplied + # in a separate call to the .update() method. + page = doc.new_page() + annot = page.add_freetext_annot( + rect, + text, + border_width=1, + dashes=None, + richtext=True, + callout=(p3, p2, rect.tr), + ) + annot.update(fill_color=gold, opacity=0.5, rotate=90) + pix2 = page.get_pixmap() + assert pix1.samples == pix2.samples