# A general-purpose PDF text-layer redaction tool.

import sys
from datetime import datetime

from pdfrw import PdfDict

class RedactorOptions:
	"""Redaction and I/O options."""

	# Input/Output
	input_stream = None
	output_stream = None

	# Metadata filters map names of entries in the PDF Document Information Dictionary
	# (e.g. "Title", "Author", "Subject", "Keywords", "Creator", "Producer", "CreationDate",
	# and "ModDate") to an array of functions to run on the values of those keys.
	#
	# Each function is given a pdfrw.objects.PdfString containing the current field value,
	# or None if the field is not present in the input PDF, as the function's first argument
	# and it must return either a string, a datetime.datetime value (CreationDate and ModDate
	# should datetime.datetime values), or None to replace the field's value with. Return
	# None to clear the field (unless a later function adds a new value).
	#
	# The functions are run in order. Each function is given the previous function's return
	# value. The last function's return value is put into the output PDF.
	#
	# If a datetime.datetime is returned without timezone info (a "naive" datetime), then
	# it must be in UTC. Use pytz.timezone.localize to encode a local time.
	#
	# Use "DEFAULT" as a key to apply functions to all metadata fields that have no specific
	# functions defined, which is useful to remove all unrecognized fields.
	#
	# Use "ALL" to appy functions to all metadata fields, after any field-specific
	# functions or DEFAULT functions are run.
	metadata_filters = { }

	# The XMP metadata filters are functions that are passed any existing XMP data and
	# return new XMP metadata. The functions are called in order and each is passed the
	# result of the previous function. The functions are given an xml.etree.Element object,
	# or None, as their first argument and must return an object of the same type, or None.
	xmp_filters = []

	# This function controls how XML returned by xmp_filters is serialized. Replace this
	# function with any function that takes an xml.etree.Element object and returns a string
	# (a unicode string --- don't serialize to bytes).
	xmp_serializer = None

	# The content filters are run on the combined content streams of the pages, as well
	# as on each annotation's text attributes separately.
	#
	# Each filter is a tuple of a compiled regular expression and a function to generate
	# replacement text, which is given a re.Match object as its sole argument. It must return a string.
	#
	# Since spaces in PDFs are sometimes not encoded as text but instead as positional
	# offsets (like newlines), the regular expression should treat all spaces as optional.
	#
	# Since pdfrw doesn't support content stream compression, you should use a tool like qpdf
	# to decompress the streams before using this tool (see the README).
	content_filters = []

	# When replacement text isn't likely to have a glyph stored in the PDF's fonts,
	# replace the character with these other characters (if they don't have the same
	# problem):
	content_replacement_glyphs = ['?', '#', '*', ' ']

	# The link filters are run on link annotations. Each link filter is a function
	# that is passed the link target (a string holding a URI) and a second argujment
	# holding the annotation object. The function should return a new URI or None to
	# remove the link.
	link_filters = []


def redactor(options):
	# This is the function that performs redaction.

	if sys.version_info < (3,):
		if options.input_stream is None:
			options.input_stream = sys.stdin # input stream containing the PDF to redact
		if options.output_stream is None:
			options.output_stream = sys.stdout # output stream to write the new, redacted PDF to
	else:
		if options.input_stream is None:
			options.input_stream = sys.stdin.buffer # input byte stream containing the PDF to redact
		if options.output_stream is None:
			options.output_stream = sys.stdout.buffer # output byte stream to write the new, redacted PDF to

	from pdfrw import PdfReader, PdfWriter

	# Read the PDF.
	document = PdfReader(options.input_stream)

	# Modify its Document Information Dictionary metadata.
	update_metadata(document, options)

	# Modify its XMP metadata.
	update_xmp_metadata(document, options)

	if options.content_filters:
		# Build up the complete text stream of the PDF content.
		text_layer = build_text_layer(document, options)

		# Apply filters to the text stream.
		update_text_layer(options, *text_layer)

		# Replace page content streams with updated tokens.
		apply_updated_text(document, *text_layer)

	# Update annotations.
	update_annotations(document, options)

	# Write the PDF back out.
	writer = PdfWriter()
	writer.trailer = document
	writer.write(options.output_stream)


def update_metadata(trailer, options):
	# Update the PDF's Document Information Dictionary, which contains keys like
	# Title, Author, Subject, Keywords, Creator, Producer, CreationDate, and ModDate
	# (the latter two containing Date values, the rest strings).

	import codecs
	from pdfrw.objects import PdfString, PdfName

	# Create the metadata dict if it doesn't exist, since the caller may be adding fields.
	if not trailer.Info:
		trailer.Info = PdfDict()

	# Get a list of all metadata fields that exist in the PDF plus any fields
	# that there are metadata filters for (since they may insert field values).
	keys = set(str(k)[1:] for k in trailer.Info.keys()) \
		 | set(k for k in options.metadata_filters.keys() if k not in ("DEFAULT", "ALL"))

	# Update each metadata field.
	for key in keys:
		# Get the functions to apply to this field.
		functions = options.metadata_filters.get(key)
		if functions is None:
			# If nothing is defined for this field, use the DEFAULT functions.
			functions = options.metadata_filters.get("DEFAULT", [])

		# Append the ALL functions.
		functions += options.metadata_filters.get("ALL", [])

		# Run the functions on any existing values.
		value = trailer.Info[PdfName(key)]
		for f in functions:
			# Before passing to the function, convert from a PdfString to a Python string.
			if isinstance(value, PdfString):
				# decode from PDF's "(...)" syntax.
				value = value.decode()

			# Filter the value.
			value = f(value)

			# Convert Python data type to PdfString.
			if isinstance(value, str) or (sys.version_info < (3,) and isinstance(value, unicode)):
				# Convert string to a PdfString instance.
				value = PdfString.from_unicode(value)

			elif isinstance(value, datetime):
				# Convert datetime into a PDF "D" string format.
				value = value.strftime("%Y%m%d%H%M%S%z")
				if len(value) == 19:
					# If TZ info was included, add an apostrophe between the hour/minutes offsets.
					value = value[:17] + "'" + value[17:]
				value = PdfString("(D:%s)" % value)

			elif value is None:
				# delete the metadata value
				pass

			else:
				raise ValueError("Invalid type of value returned by metadata_filter function. %s was returned by %s." %
					(repr(value), f.__name__ or "anonymous function"))

			# Replace value.
			trailer.Info[PdfName(key)] = value


def update_xmp_metadata(trailer, options):
	if trailer.Root.Metadata:
		# Safely parse the existing XMP data.
		from defusedxml.ElementTree import fromstring
		value = fromstring(trailer.Root.Metadata.stream)
	else:
		# There is no XMP metadata in the document.
		value = None

	# Run each filter.
	for f in options.xmp_filters:
		value = f(value)

	# Set new metadata.
	if value is None:
		# Clear it.
		trailer.Root.Metadata = None
	else:
		# Serialize the XML and save it into the PDF metadata.

		# Get the serializer.
		serializer = options.xmp_serializer
		if serializer is None:
			# Use a default serializer based on xml.etree.ElementTree.tostring.
			def serializer(xml_root):
				import xml.etree.ElementTree
				if hasattr(xml.etree.ElementTree, 'register_namespace'):
					# Beginning with Python 3.2 we can define namespace prefixes.
					xml.etree.ElementTree.register_namespace("xmp", "adobe:ns:meta/")
					xml.etree.ElementTree.register_namespace("pdf13", "http://ns.adobe.com/pdf/1.3/")
					xml.etree.ElementTree.register_namespace("xap", "http://ns.adobe.com/xap/1.0/")
					xml.etree.ElementTree.register_namespace("dc", "http://purl.org/dc/elements/1.1/")
					xml.etree.ElementTree.register_namespace("rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#")
				return xml.etree.ElementTree.tostring(xml_root, encoding='unicode' if sys.version_info>=(3,0) else None)
		
		# Create a fresh Metadata dictionary and serialize the XML into it.
		trailer.Root.Metadata = PdfDict()
		trailer.Root.Metadata.Type = "Metadata"
		trailer.Root.Metadata.Subtype = "XML"
		trailer.Root.Metadata.stream = serializer(value)


class InlineImage(PdfDict):
	def read_data(self, tokens):
		# "Unless the image uses ASCIIHexDecode or ASCII85Decode as one
		# of its filters, the ID operator should be followed by a
		# single white-space character, and the next character is
		# interpreted as the first byte of image data.
		if tokens.current[0][1] > tokens.current[0][0] + 3:
			tokens.current[0] = (tokens.current[0][0],
					tokens.current[0][0] + 3)

		start = tokens.floc
		state = 0
		whitespace = (" ", "\n", "\r")
		# 0: image data or trailing whitespace
		# 1: E
		# 2: I
		for i in range(start, len(tokens.fdata)):
			if state == 0:
				if tokens.fdata[i] == "E":
					state = 1
			elif state == 1:
				if tokens.fdata[i] == "I":
					state = 2
				else:
					state = 0
			elif state == 2:
				if tokens.fdata[i] in whitespace:
					for j in range(i + 1, i + 6):
						o = ord(tokens.fdata[j])
						if o == 0x0A:  # \n
							continue
						elif o == 0x0D:  # \r
							continue
						elif o >= 0x20 and o <= 0x7E:
							continue
						else:
							state = 0
							break
					else:
						end = i - 3
						assert tokens.fdata[end] in whitespace
						break
				else:
					state = 0

		self._stream = tokens.fdata[start:end]
		tokens.floc = end


def tokenize_streams(streams):
	# pdfrw's tokenizer PdfTokens does lexical analysis only. But we need
	# to collapse arrays ([ .. ]) and dictionaries (<< ... >>) into single
	# token entries.
	from pdfrw import PdfTokens, PdfArray
	stack = []
	for stream in streams:
		tokens = PdfTokens(stream)
		for token in tokens:
			# Is this a control token?
			if token == "<<":
				# begins a dictionary
				stack.append((PdfDict, []))
				continue
			elif token == "[":
				# begins an array
				stack.append((PdfArray, []))
				continue
			elif token in (">>", "]"):
				# ends a dictionary or array
				constructor, content = stack.pop(-1)
				if constructor == PdfDict:
					# Turn flat list into key/value pairs.
					content = chunk_pairs(content)
				token = constructor(content)
			elif token == "BI":
				# begins an inline image's dictionary half
				stack.append((InlineImage, []))
				continue
			elif token == "ID":
				# divides an inline image's dictionary half and data half
				constructor, content = stack[-1]
				content = chunk_pairs(content)
				img = constructor(content)
				img.read_data(tokens)
				stack[-1] = (img, None)
				continue
			elif token == "EI":
				# ends an inline image
				token, _ = stack.pop(-1)

			# If we're inside something, add this token to that thing.
			if len(stack) > 0:
				stack[-1][1].append(token)
				continue

			# Yield it.
			yield token


def build_text_layer(document, options):
	# Within each page's content stream, look for text-showing operators to
	# find the text content of the page. Construct a string that contains the
	# entire text content of the document AND a mapping from characters in the
	# text content to tokens in the content streams. That lets us modify the
	# tokens in the content streams when we find text that we want to redact.
	#
	# The text-showing operators are:
	#
	#   (text) Tj      -- show a string of text
	#   (text) '       -- move to next line and show a string of text
	#   aw ac (text) " -- show a string of text with word/character spacing parameters
	#   [ ... ] TJ     -- show text strings from the array, which are interleaved with spacing parameters
	#
	# (These operators appear only within BT ... ET so-called "text objects",
	# although we don't make use of it.)
	#
	# But since we don't understand any of the other content stream operators,
	# and in particular we don't know how many operands each (non-text) operator
	# takes, we can never be sure whether what we see in the content stream is
	# an operator or an operand. If we see a "Tj", maybe it is the operand of
	# some other operator?
	#
	# We'll assume we can get by just fine, however, assuming that whenever we
	# see one of these tokens that it's an operator and not an operand.
	#
	# But TJ remains a little tricky because its operand is an array that preceeds
	# it. Arrays are delimited by square brackets and we need to parse that.
	#
	# We also have to be concerned with the encoding of the text content, which
	# depends on the active font. With a simple font, the text is a string whose
	# bytes are glyph codes. With a composite font, a CMap maps multi-byte
	# character codes to glyphs. In either case, we must map glyphs to unicode
	# characters so that we can pattern match against it.
	#
	# To know the active font, we look for the "<font> <size> Tf" operator.

	from pdfrw import PdfObject, PdfString, PdfArray
	from pdfrw.uncompress import uncompress as uncompress_streams
	from pdfrw.objects.pdfname import BasePdfName

	text_tokens = []
	fontcache = { }

	class TextToken:
		value = None
		font = None
		def __init__(self, value, font):
			self.font = font
			self.raw_original_value = value
			self.original_value = toUnicode(value, font, fontcache)
			self.value = self.original_value
		def __str__(self):
			# __str__ is used for serialization
			if self.value == self.original_value:
				# If unchanged, return the raw original value without decoding/encoding.
				return PdfString.from_bytes(self.raw_original_value)
			else:
				# If the value changed, encode it from Unicode according to the encoding
				# of the font that is active at the location of this token.
				return PdfString.from_bytes(fromUnicode(self.value, self.font, fontcache, options))
		def __repr__(self):
			# __repr__ is used for debugging
			return "Token<%s>" % repr(self.value)

	def process_text(token):
		if token.value == "": return
		text_tokens.append(token)

	# For each page...
	page_tokens = []
	for page in document.pages:
		# For each token in the content stream...

		# Remember this page's revised token list.
		token_list = []
		page_tokens.append(token_list)

		if page.Contents is None:
			continue

		prev_token = None
		prev_prev_token = None
		current_font = None

		# The page may have one content stream or an array of content streams.
		# If an array, they are treated as if they are concatenated into a single
		# stream (per the spec).
		if isinstance(page.Contents, PdfArray):
			contents = list(page.Contents)
		else:
			contents = [page.Contents]

		# If a compression Filter is applied, attempt to un-apply it. If an unrecognized
		# filter is present, an error is raised. uncompress_streams expects an array of
		# streams.
		uncompress_streams(contents)

		def make_mutable_string_token(token):
			if isinstance(token, PdfString):
				token = TextToken(token.to_bytes(), current_font)

				# Remember all unicode characters seen in this font so we can
				# avoid inserting characters that the PDF isn't likely to have
				# a glyph for.
				if current_font and current_font.BaseFont:
					fontcache.setdefault(current_font.BaseFont, set()).update(token.value)
			return token

		# Iterate through the tokens in the page's content streams.
		for token in tokenize_streams(content.stream for content in contents):
			# Replace any string token with our own class that hold a mutable
			# value, which is how we'll rewrite content.
			token = make_mutable_string_token(token)

			# Append the token into a new list that holds all tokens.
			token_list.append(token)

			# If the token is an operator and we're not inside an array...
			if isinstance(token, PdfObject):
				# And it's one that we recognize, process it.
				if token in ("Tj", "'", '"') and isinstance(prev_token, TextToken):
					# Simple text operators.
					process_text(prev_token)
				elif token == "TJ" and isinstance(prev_token, PdfArray):
					# The text array operator.
					for i in range(len(prev_token)):
						# (item may not be a string! only the strings are text.)
						prev_token[i] = make_mutable_string_token(prev_token[i])
						if isinstance(prev_token[i], TextToken):
							process_text(prev_token[i])

				elif token == "Tf" and isinstance(prev_prev_token, BasePdfName):
					# Update the current font.
					# prev_prev_token holds the font 'name'. The name must be looked up
					# in the content stream's resource dictionary, which is page.Resources,
					# plus any resource dictionaries above it in the document hierarchy.
					current_font = None
					resources = page.Resources
					while resources and not current_font:
						current_font = resources.Font[prev_prev_token]
						resources = resources.Parent

			# Remember the previously seen token in case the next operator is a text-showing
			# operator -- in which case this was the operand. Remember the token before that
			# because it may be a font name for the Tf operator.
			prev_prev_token = prev_token
			prev_token = token

	return (text_tokens, page_tokens)


def chunk_pairs(s):
	while len(s) >= 2:
		yield (s.pop(0), s.pop(0))


def chunk_triples(s):
	while len(s) >= 3:
		yield (s.pop(0), s.pop(0), s.pop(0))


class CMap(object):
	def __init__(self, cmap):
		self.bytes_to_unicode = { }
		self.unicode_to_bytes = { }
		self.defns = { }
		self.usecmap = None

		# Decompress the CMap stream & check that it's not compressed in a way
		# we can't understand.
		from pdfrw.uncompress import uncompress as uncompress_streams
		uncompress_streams([cmap])

		#print(cmap.stream, file=sys.stderr)

		# This is based on https://github.com/euske/pdfminer/blob/master/pdfminer/cmapdb.py.
		from pdfrw import PdfString, PdfArray
		in_cmap = False
		operand_stack = []
		codespacerange = []

		def code_to_int(code):
			# decode hex encoding
			code = code.to_bytes()
			if sys.version_info < (3,):
				code = (ord(c) for c in code)
			from functools import reduce
			return reduce(lambda x0, x : x0*256 + x, (b for b in code))

		def add_mapping(code, char, offset=0):
			# Is this a mapping for a one-byte or two-byte character code?
			width = len(codespacerange[0].to_bytes())
			assert len(codespacerange[1].to_bytes()) == width
			if width == 1:
				# one-byte entry
				if sys.version_info < (3,):
					code = chr(code)
				else:
					code = bytes([code])
			elif width == 2:
				if sys.version_info < (3,):
					code = chr(code//256) + chr(code & 255)
				else:
					code = bytes([code//256, code & 255])
			else:
				raise ValueError("Invalid code space range %s?" % repr(codespacerange))

			# Some range operands take an array.
			if isinstance(char, PdfArray):
				char = char[offset]

			# The Unicode character is given usually as a hex string of one or more
			# two-byte Unicode code points.
			if isinstance(char, PdfString):
				char = char.to_bytes()
				if sys.version_info < (3,): char = (ord(c) for c in char)

				c = ""
				for xh, xl in chunk_pairs(list(char)):
					c += (chr if sys.version_info >= (3,) else unichr)(xh*256 + xl)
				char = c

				if offset > 0:
					char = char[0:-1] + (chr if sys.version_info >= (3,) else unichr)(ord(char[-1]) + offset)
			else:
				assert offset == 0

			self.bytes_to_unicode[code] = char
			self.unicode_to_bytes[char] = code

		for token in tokenize_streams([cmap.stream]):
			if token == "begincmap":
				in_cmap = True
				operand_stack[:] = []
				continue
			elif token == "endcmap":
				in_cmap = False
				continue
			if not in_cmap:
				continue
			
			if token == "def":
				name = operand_stack.pop(0)
				value = operand_stack.pop(0)
				self.defns[name] = value

			elif token == "usecmap":
				self.usecmap = self.pop(0)

			elif token == "begincodespacerange":
				operand_stack[:] = []
			elif token == "endcodespacerange":
				codespacerange = [operand_stack.pop(0), operand_stack.pop(0)]

			elif token in ("begincidrange", "beginbfrange"):
				operand_stack[:] = []
			elif token in ("endcidrange", "endbfrange"):
				for (code1, code2, cid_or_name1) in chunk_triples(operand_stack):
					if not isinstance(code1, PdfString) or not isinstance(code2, PdfString): continue
					code1 = code_to_int(code1)
					code2 = code_to_int(code2)
					for code in range(code1, code2+1):
						add_mapping(code, cid_or_name1, code-code1)
				operand_stack[:] = []

			elif token in ("begincidchar", "beginbfchar"):
				operand_stack[:] = []
			elif token in ("endcidchar", "endbfchar"):
				for (code, char) in chunk_pairs(operand_stack):
					if not isinstance(code, PdfString): continue
					add_mapping(code_to_int(code), char)
				operand_stack[:] = []

			elif token == "beginnotdefrange":
				operand_stack[:] = []
			elif token == "endnotdefrange":
				operand_stack[:] = []

			else:
				operand_stack.append(token)

	def dump(self):
		for code, char in self.bytes_to_unicode.items():
			print(repr(code), char)

	def decode(self, string):
		ret = []
		i = 0;
		while i < len(string):
			if string[i:i+1] in self.bytes_to_unicode:
				# byte matches a single-byte entry
				ret.append( self.bytes_to_unicode[string[i:i+1]] )
				i += 1
			elif string[i:i+2] in self.bytes_to_unicode:
				# next two bytes matches a multi-byte entry
				ret.append( self.bytes_to_unicode[string[i:i+2]] )
				i += 2
			else:
				ret.append("?")
				i += 1
		return "".join(ret)

	def encode(self, string):
		ret = []
		for c in string:
			ret.append(self.unicode_to_bytes.get(c, b""))
		return b"".join(ret)


def toUnicode(string, font, fontcache):
	# This is hard!

	if not font:
		# There is no font for this text. Assume Latin-1.
		return string.decode("Latin-1")
	elif font.ToUnicode:
		# Decompress the CMap stream & check that it's not compressed in a way
		# we can't understand.
		from pdfrw.uncompress import uncompress as uncompress_streams
		uncompress_streams([font.ToUnicode])

		# Use the CMap, which maps character codes to Unicode code points.
		if font.ToUnicode.stream not in fontcache:
			fontcache[font.ToUnicode.stream] = CMap(font.ToUnicode)
		cmap = fontcache[font.ToUnicode.stream]

		string = cmap.decode(string)
		#print(string, end='', file=sys.stderr)
		#sys.stderr.write(string)
		return string
	elif font.Encoding == "/WinAnsiEncoding":
		return string.decode("cp1252", "replace")
	elif font.Encoding == "/MacRomanEncoding":
		return string.decode("mac_roman", "replace")
	else:
		return "?"
		#raise ValueError("Don't know how to decode data from font %s." % font)

def fromUnicode(string, font, fontcache, options):
	# Filter out characters that are not likely to have renderable glyphs
	# because the character didn't occur in the original PDF in its font.
	# For any character that didn't occur in the original PDF, replace it
	# with the first character in options.content_replacement_glyphs that
	# did occur in the original PDF. If none ocurred, delete the character.
	if font and font.BaseFont in fontcache:
		char_occurs = fontcache[font.BaseFont]
		def map_char(c):
			for cc in [c] + options.content_replacement_glyphs:
				if cc in char_occurs:
					return cc
			return "" # no replacement glyph => omit character
		string = "".join(map_char(c) for c in string)

	# Encode the Unicode string in the same encoding that it was originally
	# stored in --- based on the font that was active when the token was
	# used in a text-showing operation.
	if not font:
		# There was no font for this text. Assume Latin-1.
		return string.encode("Latin-1")

	elif font.ToUnicode and font.ToUnicode.stream in fontcache:
		# Convert the Unicode code points back to one/two-byte CIDs.
		cmap = fontcache[font.ToUnicode.stream]
		return cmap.encode(string)

	# Convert using a simple encoding.
	elif font.Encoding == "/WinAnsiEncoding":
		return string.encode("cp1252")
	elif font.Encoding == "/MacRomanEncoding":
		return string.encode("mac_roman")

	# Don't know how to handle this sort of font.
	else:
		raise ValueError("Don't know how to encode data to font %s." % font)

def update_text_layer(options, text_tokens, page_tokens):
	if len(text_tokens) == 0:
		# No text content.
		return

	# Apply each regular expression to the text content...
	for pattern, function in options.content_filters:
		# Finding all matches...
		text_tokens_index = 0
		text_tokens_charpos = 0
		text_tokens_token_xdiff = 0
		text_content = "".join(t.value for t in text_tokens)
		for m in pattern.finditer(text_content):
			# We got a match at text_content[i1:i2].
			i1 = m.start()
			i2 = m.end()

			# Pass the matched text to the replacement function to get replaced text.
			replacement = function(m)

			# Do a text replacement in the tokens that produced this text content.
			# It may have been produced by multiple tokens, so loop until we find them all.
			while i1 < i2:
				# Find the original tokens in the content stream that
				# produced the matched text. Start by advancing over any
				# tokens that are entirely before this span of text.
				while text_tokens_index < len(text_tokens) and \
				      text_tokens_charpos + len(text_tokens[text_tokens_index].value)-text_tokens_token_xdiff <= i1:
					text_tokens_charpos += len(text_tokens[text_tokens_index].value)-text_tokens_token_xdiff
					text_tokens_index += 1
					text_tokens_token_xdiff = 0
				if text_tokens_index == len(text_tokens): break
				assert(text_tokens_charpos <= i1)

				# The token at text_tokens_index, and possibly subsequent ones,
				# are responsible for this text. Replace the matched content
				# here with replacement content.
				tok = text_tokens[text_tokens_index]

				# Where does this match begin within the token's text content?
				mpos = i1 - text_tokens_charpos
				assert mpos >= 0

				# How long is the match within this token?
				mlen = min(i2-i1, len(tok.value)-text_tokens_token_xdiff-mpos)
				assert mlen >= 0

				# How much should we replace here?
				if mlen < (i2-i1):
					# There will be more replaced later, so take the same number
					# of characters from the replacement text.
					r = replacement[:mlen]
					replacement = replacement[mlen:]
				else:
					# This is the last token in which we'll replace text, so put
					# all of the remaining replacement content here.
					r = replacement
					replacement = None # sanity

				# Do the replacement.
				tok.value = tok.value[:mpos+text_tokens_token_xdiff] + r + tok.value[mpos+mlen+text_tokens_token_xdiff:]
				text_tokens_token_xdiff += len(r) - mlen

				# Advance for next iteration.
				i1 += mlen

def apply_updated_text(document, text_tokens, page_tokens):
	# Create a new content stream for each page by concatenating the
	# tokens in the page_tokens lists.
	from pdfrw import PdfArray
	for i, page in enumerate(document.pages):
		if page.Contents is None: continue # nothing was here

		# Replace the page's content stream with our updated tokens.
		# The content stream may have been an array of streams before,
		# so replace the whole thing with a single new stream. Unfortunately
		# the str on PdfArray and PdfDict doesn't work right.
		def tok_str(tok):
			if isinstance(tok, PdfArray):
				return "[ " + " ".join(tok_str(x) for x in tok) + "] "
			if isinstance(tok, InlineImage):
				return "BI " + " ".join(tok_str(x) + " " + tok_str(y) for x,y in tok.items()) + " ID " + tok.stream + " EI "
			if isinstance(tok, PdfDict):
				return "<< " + " ".join(tok_str(x) + " " + tok_str(y) for x,y in tok.items()) + ">> "
			return str(tok)
		page.Contents = PdfDict()
		page.Contents.stream = "\n".join(tok_str(tok) for tok in page_tokens[i])
		page.Contents.Length = len(page.Contents.stream) # reset


def update_annotations(document, options):
	for page in document.pages:
		if hasattr(page, 'Annots') and isinstance(page.Annots, list):
			for annotation in page.Annots:
				update_annotation(annotation, options)

def update_annotation(annotation, options):
	import re
	from pdfrw.objects import PdfString

	# Contents holds a plain-text representation of the annotation
	# content, such as for accessibility. All annotation types may
	# have a Contents. NM holds the "annotation name" which also
	# could have redactable text, I suppose. Markup annotations have
	# "T" fields that hold a title / text label. Subj holds a
	# comment subject. CA, RC, and AC are used in widget annotations.
	for string_field in ("Contents", "NM", "T", "Subj", "CA", "RC", "AC"):
		if getattr(annotation, string_field):
			value = getattr(annotation, string_field).to_unicode()
			for pattern, function in options.content_filters:
				value = pattern.sub(function, value)
			setattr(annotation, string_field, PdfString.from_unicode(value))

	# A rich-text stream. Not implemented. Bail so that we don't
	# accidentally leak something that should be redacted.
	if annotation.RC:
		raise ValueError("Annotation rich-text streams (Annot/RC) are not supported.")

	# An action, usually used for links.
	if annotation.A:
		update_annotation_action(annotation, annotation.A, options)
	if annotation.PA:
		update_annotation_action(annotation, annotation.PA, options)

	# If set, another annotation.
	if annotation.Popup:
		update_annotation(annotation.Popup, options)

	# TODO? Redaction annotations have some other attributes that might
	# have text. But since they're intended for redaction... maybe we
	# should keep them anyway.

def update_annotation_action(annotation, action, options):
	from pdfrw.objects import PdfString

	if action.URI and options.link_filters:
		value = action.URI.to_unicode()
		for func in options.link_filters:
			value = func(value, annotation)
		if value is None:
			# Remove annotation by supressing the action.
			action.URI = None
		else:
			action.URI = PdfString.from_unicode(value)

	if action.Next:
		# May be an Action or array of Actions to execute next.
		next_action = action.Next
		if isinstance(action.Next, dict):
			next_action = [action.Next]
		for a in next_action:
			update_annotation_action(annotation, a, options)


if __name__ == "__main__":
	redactor(RedactorOptions())
