I am trying to apply formatting to a document that already exists. I am trying to highlight text within the document, and I would like to preserve formatting around the changes I make.
For example, highlight yellow all occurrences of "test" in the paragraph "This is a test. This is only a test"
In this example the paragraph is in one run to start, but will be in four runs after highlighting ["This is a ", 'test", ". This is only a ", "test"].
It is not immediately obvious how to accomplish this goal. There are no utility functions that I can find for copying the formatting of a run, or for splitting a run into multiple runs, or for adding a run to an arbitrary location in the paragraph. I am considering either constructing a whole new document paragraph by paragraph (grossly inefficient) or manually implementing run.split(start, end) and paragraph.addRunAt(n) functions.
Is there a better way that I'm just missing? If I make the functions I described would they be useful to add to the package?
I think your approach is about right. I think the right general architecture is to create functions or possibly wrapper objects that compose a proxy object (like Paragraph or Run) or the lxml element objects themselves. This way your work can be used by others without them having to use a fork or you having to get a commit (which is harder than most folks are willing to undertake).
I think I would work at the paragraph level and consider using character position (within the paragraph) as the key parameter(s). So you could get a sequence of runs between character positions 15 and 42, say, and then apply something to all those. The method that gave those to you would split runs if necessary to start and 15 and end at 42.
I think the formatting copy you could do with a deepcopy on the rPr element, possibly excepting certain attributes like revision-id or whatever might be sensible to skip.
I think it's an interesting problem, pretty abstract, and an opportunity to use some interesting data structures. I'd love to see what you come up with and happy to consider any questions you come up with as you go.
Here's my solution: https://github.com/aekoch/docx_formatting_aekoch.git
I haven't tested all the edge cases, but as you can see from the result.docx document it correctly applies formatting to all of the occurances of "test" in the original document.
I am having trouble copying the format though. The main problem is that both the font property of the run and the rPr property of the element throw an Attribute Error when I try to set them. I manually implemented a deep copy of fonts. That's fine for my purposes, but I don't like that there is no general solution. Any thoughts on where I could actually set the rPr property for a run? I think the ability to copy the formatting of one run to another would be useful enough to add to the package, maybe to the Font class.
The other problem I had was that I needed to access and modify the _p attribute of the paragraph object to get the runs to actually change order. When I changed the order of the runs in the paragraph.runs object it didn't actually affect the order of the runs in the saved document. Messing with the private variables probably isn't a good long term solution, but it was the only way I found to affect the order of the runs.
It seems to me that the central part of aekochs code is the line
paragraph._p[index_in_paragraph+1:index_in_paragraph+1] = [new_run.element]
I tried something similar in my code:
para._p.insert(runX, new_run._r)
Our goal is to move a run that was appended to the end of the paragraph.runs list to a different place in the list. In my case I was astonished that this did not work. I splitted the third run of six runs, runX was 3, but the new_run ended up in the second position of the list, irrespective of the value of runX!? PyCharm does not let me debug into the insert call, and after hours of trial and error I came up with this solution:
def split_run(para, runs, run, x):
runX = runs.index(run) + 1
t1 = run.text[0:x]
t2 = run.text[x:]
run.text = t1
new_run = add_run_copy(para, run, text=t2)
# the insert does not work as expected, the new_run is always inserted into the same place,
# irrespective of runX
# para._p.insert(runX, new_run._r)
# therefore, we append all runs behind runX AFTER the newly appended run
# i.e. we copy a b t1 c d t2 to a b t1 t2 c d, by appending c, d
# this is all trial and error, and completely obscure...
while runX < len(runs):
para._p.append(runs[runX]._r)
runX += 1
print("splitRes:", " ".join(["<" + run.text + ">" for run in para.runs]))
Btw, add_run_copy is mainly a call of paragraph.add_run, then I try to copy as many properties from the run to the copy as possible:
def add_run_copy(paragraph, run, text=None):
r = paragraph.add_run(text=run.text if text is None else text, style=run.style)
r.bold = run.bold
r.italic = run.italic
r.underline = run.underline
r.font.all_caps = run.font.all_caps
r.font.bold = run.font.bold
r.font.color.rgb = run.font.color.rgb
r.font.color.theme_color = run.font.color.theme_color
#r.font.color.type = run.font.color.type
r.font.complex_script = run.font.complex_script
r.font.cs_bold = run.font.cs_bold
r.font.cs_italic = run.font.cs_italic
r.font.double_strike = run.font.double_strike
r.font.emboss = run.font.emboss
r.font.hidden = run.font.hidden
r.font.highlight_color = run.font.highlight_color
r.font.imprint = run.font.imprint
r.font.italic = run.font.italic
r.font.math = run.font.math
r.font.name = run.font.name
r.font.no_proof = run.font.no_proof
r.font.outline = run.font.outline
r.font.rtl = run.font.rtl
r.font.shadow = run.font.shadow
r.font.size = run.font.size
r.font.small_caps = run.font.small_caps
r.font.snap_to_grid = run.font.snap_to_grid
r.font.spec_vanish = run.font.spec_vanish
r.font.strike = run.font.strike
r.font.subscript = run.font.subscript
r.font.superscript = run.font.superscript
r.font.underline = run.font.underline
r.font.web_hidden = run.font.web_hidden
return r
But can someone (scanny?) perhaps comment on this code, and why the insert did not work?
Moving a run element (w:r) (or any element) must take place at the lxml level using lxml calls. No assignment at the proxy-object level (i.e. Run object) will get that done:
>>> paragraph = document.add_paragraph()
>>> run = paragraph.add_run('foo')
>>> paragraph_2 = document.add_paragraph()
>>> run_2 = paragraph_2.add_run('bar')
>>> len(paragraph.runs), len(paragraph_2.runs)
(1, 1)
>>> paragraph.text, paragraph_2.text
('foo', 'bar')
>>> run._r.addnext(run_2._r)
>>> len(paragraph.runs), len(paragraph_2.runs)
(2, 0)
>>> paragraph.text, paragraph_2.text
('foobar', '')
The basic idea is that when you assign/attach an lxml element to another parent or sibling, that element is _moved_, not copied.
The various lxml methods you need are documented here:
Regarding the "cloning" of run formatting, you might try something like this:
>>> from copy import deepcopy
>>> run_source, run_target = # ---however you get references to these---
>>> rPr_source = run_source._r.get_or_add_rPr()
>>> rPr_clone = deepcopy(rPr_source)
>>> run_target._r.rPr = rPr_clone
I'm not sure about the assignment at the end; we might need to do something slightly fancier. Let me know if that doesn't work. It might need something like:
>>> rPr_target = run_target._r.get_or_add_rPr()
>>> rPr_target.addnext(rPr_clone)
>>> run_target._r.remove(rPr_target)
@scanny Hi, I just tried your format cloning method mentioned above. I found that I couldn't set run_target._r. which caused AttributeError: "can't set attribute". Do you have any other methods to realize cloning run formats beside @michaelu123's method?
Hi, I created a small project based on @scanny suggestions. It provides functions to:
Here it is: https://github.com/alllexx88/python-docx-split-run
Most helpful comment
Regarding the "cloning" of run formatting, you might try something like this:
I'm not sure about the assignment at the end; we might need to do something slightly fancier. Let me know if that doesn't work. It might need something like: