from datetime import datetime
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.utils.datastructures import SortedDict
from ldt.ldt_utils.models import Content, Project
from ldt.ldt_utils.stat import get_string_from_buckets
from ldt.ldt_utils.utils import reduce_text_node
import lxml.etree
import md5
import uuid
User = get_user_model()
DATE_FORMATS = ["%d/%m/%Y", "%Y-%m-%d"]
import logging
logger = logging.getLogger(__name__)
"""
Serialize a project object to a cinelab compatible array
"""
class ProjectJsonSerializer:
def __init__(self, project, from_contents=True, from_display=True, first_cutting=None, only_one_cutting=False):
self.project = project
self.parsed = False
self.ldt_doc = None
self.medias_dict = SortedDict()
self.annotations_dict = SortedDict()
self.annotations_by_annotation_types = {}
self.tags = {}
self.tags_dict = SortedDict()
self.annotation_types_dict = SortedDict()
self.views_dict = SortedDict()
self.lists_dict = SortedDict()
self.serialize_contents = from_contents
self.from_display = from_display
self.display_contents_list = []
self.display_cuttings_list = []
self.display_ensemble_list = []
self.first_cutting = first_cutting
self.only_one_cutting = only_one_cutting
# if first_cutting, it means that we limit to the concerned media=
self.one_content= False
def __parse_views(self, display_node_list):
for display_node in display_node_list:
display_id = display_node.get(u"id", None)
if not display_id:
continue
content_list = []
cuttings_list = []
new_display = {
"id": display_id,
"contents": content_list,
"annotation_types": cuttings_list,
}
for content_node in display_node.xpath("content"):
content_id = content_node.get("id")
if content_id not in content_list:
content_list.append(content_id)
if content_id not in self.display_contents_list:
self.display_contents_list.append(content_id)
for cutting_node in content_node.xpath("decoupage"):
cutting_id = cutting_node.get("id")
if cutting_id not in cuttings_list:
cuttings_list.append(cutting_id)
if cutting_id not in self.display_cuttings_list:
self.display_cuttings_list.append(cutting_id)
ensemble_id = cutting_node.get("idens")
if ensemble_id not in self.display_ensemble_list:
self.display_ensemble_list.append(ensemble_id)
# sets cutting to display in first position for the metadataplayer
if self.first_cutting:
annotation_types = new_display['annotation_types']
if self.first_cutting not in annotation_types:
annotation_types.append(self.first_cutting)
index = -1
for i, s in enumerate(annotation_types):
if s == self.first_cutting:
index = i
break
annotation_types[0], annotation_types[index] = annotation_types[index], annotation_types[0]
if self.only_one_cutting:
new_display['annotation_types'] = [new_display['annotation_types'][0]]
self.views_dict[display_id] = new_display
def __parse_ensemble(self, ensemble_node, content, cutting_only=None):
ensemble_id = ensemble_node.attrib[u"id"]
ensemble_author = ensemble_node.attrib[u"author"]
ensemble_title = ensemble_node.attrib[u"title"]
ensemble_description = ensemble_node.attrib[u"abstract"]
ensemble_created = datetime.utcnow().isoformat()
ensemble_modified = ensemble_created
list_items = []
new_list = {
"id" : ensemble_id,
"items" : list_items,
"meta" : {
"dc:creator":ensemble_author,
"dc:created": ensemble_created,
"dc:contributor":"undefined",
"dc:modified": ensemble_modified,
"dc:title":ensemble_title,
"dc:description": ensemble_description,
"id-ref":content.iri_id,
"editable":"false"
}
}
if cutting_only:
cuttings_list = cutting_only
else:
cuttings_list = ensemble_node
for decoupage_node in cuttings_list:
if decoupage_node.tag != "decoupage" :
continue
decoupage_id = decoupage_node.attrib[ u"id"]
if not cutting_only and self.from_display and decoupage_id not in self.display_cuttings_list:
continue
decoupage_creator = decoupage_node.attrib[u"author"]
if decoupage_creator=="perso":
decoupage_creator = self.project.owner.username
if not decoupage_creator:
decoupage_creator = "IRI"
decoupage_contributor = decoupage_creator
date_str = decoupage_node.get(u"date")
decoupage_created = None
if date_str :
for date_format in DATE_FORMATS:
try:
decoupage_created = datetime.strptime(date_str, date_format).isoformat()
break
except Exception:
decoupage_created = None
if decoupage_created is None:
decoupage_created = datetime.utcnow().isoformat()
decoupage_modified = decoupage_created
decoupage_title = ""
for txtRes in decoupage_node.xpath("title/text()", smart_strings=False):
decoupage_title += txtRes
decoupage_description = ""
for txtRes in decoupage_node.xpath("abstract/text()", smart_strings=False):
decoupage_description += txtRes
list_items.append({"id-ref":decoupage_id})
new_annotation_types = {
"id":decoupage_id,
"dc:creator":decoupage_creator,
"dc:created":decoupage_created,
"dc:contributor":decoupage_contributor,
"dc:modified":decoupage_modified,
"dc:title":decoupage_title,
"dc:description":decoupage_description
}
self.annotation_types_dict[decoupage_id] = new_annotation_types
self.annotations_by_annotation_types[decoupage_id] = []
res = decoupage_node.xpath("elements/element")
for element_node in res:
element_id = element_node.attrib[u"id"]
element_begin = element_node.attrib[u"begin"]
element_duration = element_node.attrib[u"dur"]
element_media = content.iri_id
element_color = element_node.attrib.get(u"color", "")
element_ldt_src = element_node.attrib.get(u"src", "")
element_title = reduce_text_node(element_node, "title/text()")
element_description = reduce_text_node(element_node, "abstract/text()")
element_source_node_list = element_node.xpath("meta/source")
if len(element_source_node_list) > 0:
element_source_node = element_source_node_list[0]
element_source = {"mimetype" :element_source_node.get(u'mimetype'), "url":element_source_node.get(u'url'), "content":reduce_text_node(element_source_node)}
else:
element_source = None
element_audio_src = ""
element_audio_href = ""
res = element_node.xpath("audio")
if len(res) > 0:
element_audio_src = res[0].get(u"source", u"")
element_audio_href = res[0].text
element_tags = []
tags = element_node.get(u"tags", u"")
tags_list = map(lambda s:s.strip(), tags.split(","))
#tags
if tags is None or len(tags) == 0:
tags_list = []
restagnode = element_node.xpath("tag/text()", smart_strings=False)
for tagnode in restagnode:
tags_list.append(tagnode)
if tags_list is None or len(tags_list) == 0:
tags_list = []
restagnode = element_node.xpath("tags/tag/text()", smart_strings=False)
for tagnode in restagnode:
tags_list.append(tagnode)
tag_date = datetime.utcnow().isoformat()
for tag_title in tags_list:
if tag_title not in self.tags:
# md5 instead of uuid to get an almost unicity
tag_id = unicode(md5.new(tag_title.encode('utf-8')).hexdigest())
new_tag = {
"id":tag_id,
"meta" : {
"dc:creator":"IRI",
"dc:created": tag_date,
"dc:contributor":"IRI",
"dc:modified": tag_date,
"dc:title":tag_title
}
}
self.tags[tag_title] = new_tag
self.tags_dict[tag_id] = new_tag
else:
tag_id = self.tags[tag_title]["id"]
element_tags.append({"id-ref":tag_id})
if not element_tags:
element_tags = None
annot_creator = element_node.attrib[u"author"]
if annot_creator=="perso":
annot_creator = decoupage_creator
if not annot_creator:
annot_creator = decoupage_creator
new_annotation = {
"begin": int(float(element_begin)),
"end": int(float(element_begin)) + int(float(element_duration)),
"id": element_id,
"media": element_media,
"color": element_color,
"content": {
"mimetype": "application/x-ldt-structured",
"title": element_title,
"description": element_description,
#"color": element_color,
"img": {
"src": element_ldt_src,
},
"audio": {
"src" : element_audio_src,
"mimetype": "audio/mp3",
"href": element_audio_href
},
"polemics" :[pol_elem.text for pol_elem in element_node.xpath("meta/polemics/polemic")],
},
"tags": element_tags,
"meta": {
"id-ref": decoupage_id,
"dc:creator": annot_creator,
"dc:contributor": decoupage_contributor,
"dc:created": decoupage_created,
"dc:modified": decoupage_modified,
}
}
if element_source:
new_annotation['meta']['dc:source'] = element_source
# Metadatacomposer features. An annotation can have the usual datas (title, description...)
# and new kinds of extra metas : video, audio, text, links array, images slideshow
# Get type
meta_type_node = element_node.xpath("meta/type")
if len(meta_type_node) > 0:
meta_type = reduce_text_node(meta_type_node[0], "text()")
# Update mimetype and add datas
if meta_type=="video":
new_annotation["content"]["mimetype"] = "application/x-ldt-video"
new_annotation["content"]["url"] = reduce_text_node(element_node, "meta/url/text()")
new_annotation["content"]["embedcode"] = reduce_text_node(element_node, "meta/embedcode/text()")
elif meta_type=="audio":
new_annotation["content"]["mimetype"] = "application/x-ldt-audio"
new_annotation["content"]["url"] = reduce_text_node(element_node, "meta/url/text()")
new_annotation["content"]["embedcode"] = reduce_text_node(element_node, "meta/embedcode/text()")
elif meta_type=="text":
new_annotation["content"]["mimetype"] = "application/x-ldt-text"
new_annotation["content"]["markup"] = reduce_text_node(element_node, "meta/markup/text()")
new_annotation["content"]["text"] = reduce_text_node(element_node, "meta/text/text()")
elif meta_type=="links":
new_annotation["content"]["mimetype"] = "application/x-ldt-links"
new_annotation["content"]["links"] = []
link_nodes = element_node.xpath("meta/links/link")
for link in link_nodes:
new_annotation["content"]["links"].append({"url": reduce_text_node(link, "url/text()"), "title":reduce_text_node(link, "title/text()")})
elif meta_type=="slideshow":
new_annotation["content"]["mimetype"] = "application/x-ldt-slideshow"
new_annotation["content"]["slideduration"] = reduce_text_node(element_node, "meta/slideduration/text()")
new_annotation["content"]["autostart"] = {'true': True, 'false': False, "0": False, "1": True}.get(reduce_text_node(element_node, "meta/autostart/text()").lower())
new_annotation["content"]["images"] = []
image_nodes = element_node.xpath("meta/images/image")
for image in image_nodes:
new_annotation["content"]["images"].append({"url": reduce_text_node(image, "url/text()"), "title":reduce_text_node(image, "title/text()"), "description":reduce_text_node(image, "description/text()")})
self.annotations_dict[element_id] = new_annotation
self.annotations_by_annotation_types[decoupage_id].append(new_annotation)
if not list_items:
new_list["items"] = None
self.lists_dict[ensemble_id] = new_list
def __parse_ldt(self):
self.ldt_doc = lxml.etree.fromstring(self.project.ldt_encoded)
if self.from_display:
xpath_str = "/iri/displays/display[position()=1]"
if isinstance(self.from_display, basestring):
xpath_str = "/iri/displays/display[@id='%s']" % self.from_display
self.__parse_views(self.ldt_doc.xpath(xpath_str))
# getting all contents at once
# If self.one_content, we remove the other content
if self.first_cutting and self.one_content:
contents_iri_id = list(
set(self.ldt_doc.xpath('/iri/annotations/content[ensemble/decoupage/@id=\'%s\']/@id' % self.first_cutting))
)
else:
contents_iri_id = list(
set(self.ldt_doc.xpath("/iri/medias/media/@id")) |
set(self.ldt_doc.xpath("/iri/annotations/content/@id")) |
(set(self.ldt_doc.xpath('/iri/annotations/content[ensemble/decoupage/@id=\'%s\']/@id' % self.first_cutting)) if self.first_cutting and self.first_cutting not in self.display_cuttings_list else set())
)
contents = dict([ (c.iri_id, c) for c in Content.objects.filter(iri_id__in=contents_iri_id).select_related('media_obj', 'stat_annotation').prefetch_related("authors")])
m_cls = ContentType.objects.get(model='media')
m_cls = m_cls.model_class()
medias = dict([ (m.id, m) for m in m_cls.safe_objects.filter(id__in = [c.media_obj.id for c in contents.values() if c.media_obj])])
res = self.ldt_doc.xpath("/iri/medias/media")
for mediaNode in res:
iri_id = mediaNode.attrib[u"id"]
if (self.from_display and iri_id not in self.display_contents_list) or iri_id not in contents_iri_id:
continue
content = contents[iri_id]#Content.objects.get(iri_id=iri_id) #@UndefinedVariable
self.__parse_content(content, medias)
res = self.ldt_doc.xpath("/iri/annotations/content")
for content_node in res:
content_id = content_node.attrib[u"id"]
if (self.from_display and content_id not in self.display_contents_list) or content_id not in contents_iri_id:
continue
content = contents[content_id]#Content.objects.get(iri_id=content_id) #@UndefinedVariable
for ensemble_node in content_node:
if ensemble_node.tag != "ensemble" :
continue
ensemble_id = ensemble_node.get("id")
if self.from_display and ensemble_id not in self.display_ensemble_list:
continue
self.__parse_ensemble(ensemble_node, content)
if self.first_cutting and self.first_cutting not in self.display_cuttings_list:
cutting_node= self.ldt_doc.xpath('/iri/annotations/content/ensemble/decoupage[@id=\'%s\']' % self.first_cutting)[0]
ensemble_node = cutting_node.xpath('..')[0]
content_node = ensemble_node.xpath('..')[0]
iri_id = content_node.get("id")
content = contents[iri_id]#Content.objects.get(iri_id=iri_id)
self.__parse_ensemble(ensemble_node, content, cutting_only=[cutting_node])
#reorder annotations and annotation type from view
if self.from_display and len(self.views_dict) > 0:
new_annotation_types_dict = SortedDict()
new_annotations_dict = SortedDict()
for annotation_type in self.display_cuttings_list + [self.first_cutting]:
if annotation_type in self.annotation_types_dict:
new_annotation_types_dict[annotation_type] = self.annotation_types_dict[annotation_type]
for annot in self.annotations_by_annotation_types[annotation_type]:
new_annotations_dict[annot['id']] = annot
self.annotations_dict = new_annotations_dict
self.annotation_types_dict = new_annotation_types_dict
# We add the first "bout a bout". It is an "edit" in ldt format and list/listtype="mashup" in json format
self.__parse_edits()
self.parsed = True
def __parse_edits(self):
editings = self.ldt_doc.xpath("/iri/edits/editing")
if not editings:
return False
editing = self.ldt_doc.xpath("/iri/edits/editing[position()=1]")[0]
e_id = editing.get("id");
eList = editing.xpath("edit[position()=1]/eList")[0]
d = datetime.utcnow().isoformat()
e_title = ""
for txtRes in editing.xpath("title/text()", smart_strings=False):
e_title += txtRes
e_description = ""
for txtRes in editing.xpath("abstract/text()", smart_strings=False):
e_description += txtRes
list_items = []
for item in eList:
# ref is structured with contenated ids : id_content|;|id_ensemble|;|id_annot-type|;||;||;|id_SEG
ids = item.get("ref").split('|;|')
list_items.append(ids[-1])
# We build the jsonable object if necessary
if len(list_items)>0:
new_list = {
"id" : e_id,
"items" : list_items,
"meta" : {
"dc:creator":"IRI",
"dc:created": d,
"dc:contributor":"IRI",
"dc:modified": d,
"dc:title": e_title,
"dc:description": e_description,
"listtype":"mashup"
}
}
self.lists_dict[e_id] = new_list
def __parse_content(self, content, medias):
doc = lxml.etree.parse(content.iri_file_path())
authors = content.authors.all()
if len(authors) > 0 :
author = authors[0].handle
else :
author = "IRI"
if len(authors) > 1 :
contributor = authors[1].handle
else :
contributor = author
content_author = ""
res = doc.xpath("/iri/head/meta[@name='author']/@content")
if len(res) > 0:
content_author = res[0]
content_date = ""
res = doc.xpath("/iri/head/meta[@name='date']/@content")
if len(res) > 0:
content_date = res[0]
url = ""
meta_item_value = ""
if content.media_obj and content.media_obj.id not in medias:
url = settings.FORBIDDEN_STREAM_URL
elif content.videopath:
url = content.videopath.rstrip('/') + "/" + content.src
meta_item_value = content.videopath.rstrip('/') + "/"
else:
url = content.src
new_media = {
"http://advene.liris.cnrs.fr/ns/frame_of_reference/ms" : "o=0",
"id" : content.iri_id,
"url" : url,
"unit" : "ms",
"origin" : "0",
"meta": {
"dc:creator" : author,
"dc:created" : content.creation_date.isoformat(),
"dc:contributor" : contributor,
"dc:modified" : content.update_date.isoformat(),
"dc:creator.contents" : content_author,
"dc:created.contents" : content_date,
"dc:title" : content.title,
"dc:description" : content.description,
"dc:duration" : content.get_duration(),
"item": {
"name" : "streamer",
"value": meta_item_value,
},
}
}
self.medias_dict[content.iri_id] = new_media
new_display = {
"id": "stat",
"contents": [content.iri_id],
"meta": {
"stat": get_string_from_buckets(content.annotation_volume),
}
}
self.views_dict['test'] = new_display
if self.serialize_contents:
res = doc.xpath("/iri/body/ensembles/ensemble")
for ensemble_node in res:
self.__parse_ensemble(ensemble_node, content)
def serialize_to_cinelab(self, one_content_param=False):
res = {}
self.one_content = one_content_param
if not self.parsed:
self.__parse_ldt()
project_main_media = ""
if len(self.medias_dict) > 0:
project_main_media = self.medias_dict.value_for_index(0)["id"]
res['meta'] = {
'id': self.project.ldt_id,
'dc:created':self.project.creation_date.isoformat(),
'dc:modified':self.project.modification_date.isoformat(),
'dc:contributor':self.project.changed_by,
'dc:creator':self.project.created_by,
'dc:title':self.project.title,
'dc:description':self.project.get_description(self.ldt_doc), # get from doc, parse ldt
'main_media': {"id-ref":project_main_media}
}
res['medias'] = self.medias_dict.values() if len(self.medias_dict) > 0 else None
res['lists'] = self.lists_dict.values() if len(self.lists_dict) > 0 else None
res['tags'] = self.tags.values() if len(self.tags) > 0 else None
res['views'] = self.views_dict.values() if len(self.views_dict) > 0 else None
res['annotation-types'] = self.annotation_types_dict.values() if len(self.annotation_types_dict) > 0 else None
res['annotations'] = self.annotations_dict.values() if len(self.annotations_dict) > 0 else None
return res
def get_annotations(self, first_cutting=True):
if not self.parsed:
self.__parse_ldt()
annotations = []
current_cutting = None
uri = None
for annot in self.annotations_dict.values():
logging.debug("current cutting" + repr(current_cutting) + " : annot " + annot['meta']['id-ref']) #@UndefinedVariable
if first_cutting and current_cutting and current_cutting != annot['meta']['id-ref'] :
break
current_cutting = annot['meta']['id-ref']
content_id = annot['media']
content = Content.objects.get(iri_id=content_id) #@UndefinedVariable
if annot['tags']:
tags_list = map(lambda tag_entry: self.tags_dict[tag_entry['id-ref']]['meta']['dc:title'], annot['tags'])
else:
tags_list = []
begin = int(annot['begin'])
duration = int(annot['end']) - begin
if content.media_obj and content.media_obj.external_publication_url:
uri = "%s#t=%d" % (content.media_obj.external_publication_url, begin)
annotations.append({
'begin': begin,
'duration':duration,
'title':annot['content']['title'],
'desc':annot['content']['description'],
'tags': tags_list,
'id':annot['id'],
'uri':uri
})
return annotations
"""
Quick and dirty converter from cinelab JSON to ldt format.
Does not support imports, mutliple medias, or media creation
"""
class JsonCinelab2Ldt:
def create_json(self, json):
medias = json['medias']
contentList = []
for media in medias:
c = Content.objects.get(iri_id=media['id'])
if c != None:
contentList.append(c)
meta = json['meta']
creator = meta['creator']
contributor = meta['contributor']
user = User.objects.get(username=creator)
project = Project.create_project(user, creator + '_' + contributor, contentList)
project.changed_by = contributor
ldtdoc = lxml.etree.fromstring(project.ldt_encoded)
element = ldtdoc.xpath('/iri/annotations')
for media in contentList:
content = lxml.etree.Element('content')
content.set('id', media.iri_id)
annotation_types = json['annotation_types']
cuttings = {}
if len(annotation_types) > 0:
media = lxml.etree.SubElement(element[0], 'content')
media.set('id', medias[0]['id'])
ens = lxml.etree.SubElement(media, 'ensemble')
ens.set('title', 'Decoupages personnels')
ens.set('idProject', project.ldt_id)
ens.set('abstract', '')
ens.set('id', 'g_' + str(uuid.uuid1()))
for i in annotation_types:
cutting_infos = {'desc' : i['meta']['description']}
dec = lxml.etree.SubElement(ens, 'decoupage')
dec.set('author', contributor)
dec.set('id', 'c_' + str(uuid.uuid1()))
elements_list = lxml.etree.SubElement(dec, 'elements')
title = lxml.etree.SubElement(dec, 'title')
title.text = i['id']
abstract = lxml.etree.SubElement(dec, 'abstract')
abstract.text = i['meta']['description']
cutting_infos['xml_node'] = elements_list
cuttings[i['id']] = cutting_infos
annotations = json['annotations']
for i in annotations:
cutting_infos = cuttings[i['type']]
elements_node = cutting_infos['xml_node']
element = lxml.etree.SubElement(elements_node, 'element')
element.set('begin', str(i['begin']))
element.set('dur', str(i['end'] - i['begin']))
element.set('id', 's_' + str(uuid.uuid1()))
title = lxml.etree.SubElement(element, 'title')
audio = lxml.etree.SubElement(element, 'audio')
audio.set('source', 'undefined')
abstract = lxml.etree.SubElement(element, 'abstract')
abstract.text = i['content']['data']
tags = lxml.etree.SubElement(element, 'tags')
for tag in i['tags']:
tag_xml = lxml.etree.SubElement(tags, 'tag')
tag_xml.text = tag
project.ldt = lxml.etree.tostring(ldtdoc, pretty_print=True)
project.save()
return project.ldt
"""
Merge several projects in one for a given content. All ensembles are copied into one project
"""
class ProjectMerger:
def __init__(self, content, projects):
self.content = content
self.projects = projects
def get_merged_project(self, shot_by_shot=True, only_visible=True):
# New project
contents = [ self.content, ]
# Get user
user = User.objects.get(username="admin")
proj = Project.create_project(title="Merged project",
user=user, contents=contents,
description=u"", set_icon=False)
doc = lxml.etree.fromstring(proj.ldt_encoded)
annot_node = doc.xpath("/iri/annotations")[0]
content_node = lxml.etree.SubElement(annot_node, 'content')
content_node.set('id', self.content.iri_id)
display_node = doc.xpath('/iri/displays/display')[0]
ctt_disp_node = display_node.xpath('content[@id="' + self.content.iri_id + '"]')[0]
# remove shot by shot from display
if not shot_by_shot:
dec_node = ctt_disp_node.xpath('decoupage[@id="de_PPP"]')
if len(dec_node)>0:
dec_node = dec_node[0]
if dec_node is not None:
ctt_disp_node.remove(dec_node)
# Parse all projects
for p in self.projects:
p_xml = lxml.etree.fromstring(p.ldt_encoded)
# We only keep the decoupages (cuttings) visible in the default view, which means the first display.
first_display = p_xml.xpath('/iri/displays/display')[0]
current_disp_node = first_display.xpath('content[@id="' + self.content.iri_id + '"]')[0]
# First version of ensemble
ens = p_xml.xpath('/iri/annotations/content[@id="' + self.content.iri_id + '"]/ensemble')
for e in ens:
content_node.append(e)
# Update display
for c in e.xpath('decoupage'):
if not only_visible or (only_visible and len(current_disp_node.xpath('decoupage[@id="' + c.get('id') + '"]'))>0 ) :
c_node = lxml.etree.SubElement(ctt_disp_node, 'decoupage')
c_node.set(u'idens', e.get('id'))
c_node.set(u'id', c.get('id'))
# Second version of ensemble
ens = p_xml.xpath('/iri/annotations/content[@id="' + self.content.iri_id + '"]/ensembles/ensemble')
for e in ens:
content_node.append(e)
# Update display
for c in e.xpath('decoupage'):
if not only_visible or (only_visible and len(current_disp_node.xpath('decoupage[@id="' + c.get('id') + '"]'))>0 ) :
c_node = lxml.etree.SubElement(ctt_disp_node, 'decoupage')
c_node.set(u'idens', e.get('id'))
c_node.set(u'id', c.get('id'))
proj.ldt = lxml.etree.tostring(doc, pretty_print=True)
return proj