From 454a135d38574ec700ff9908d7da23030ec0d8e2 Mon Sep 17 00:00:00 2001 From: tans1q Date: Sun, 14 Jul 2024 20:03:16 +0200 Subject: [PATCH] WIP --- .gitignore | 9 +- index.json | 0 index.json.backup | 313 ------------- requirements.txt | 3 +- src/annotations_indexer.py | 176 +++++++ src/cli.py | 27 +- src/consts.py | 21 +- src/dispatch.py | 111 ++++- src/file_utils.py | 8 + src/integration/airtable.py | 64 +++ src/integration/s3.py | 111 +++++ src/integration/yandex_disk.py | 24 + src/layout_analysis.py | 196 ++++++-- src/layout_labeling.py | 69 --- src/s3_uploader.py | 54 --- src/text_extraction.py | 440 +++++++++++++----- .../4f10abe73cc399ca31c3a9a13932a8f8.md | 80 ---- 17 files changed, 984 insertions(+), 722 deletions(-) delete mode 100644 index.json delete mode 100644 index.json.backup create mode 100644 src/annotations_indexer.py create mode 100644 src/integration/airtable.py create mode 100644 src/integration/s3.py create mode 100644 src/integration/yandex_disk.py delete mode 100644 src/layout_labeling.py delete mode 100644 src/s3_uploader.py delete mode 100644 workdir/900_artifacts/4f10abe73cc399ca31c3a9a13932a8f8.md diff --git a/.gitignore b/.gitignore index bc0a75a..5a7cbcf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,6 @@ -workdir/000_entry_point -workdir/100_dirty -workdir/500_not_a_document -workdir/510_not_supported_yet -workdir/520_not_tt_document -workdir/910_extracted_docs +workdir/* +!workdir/900_artifacts + 0_to_process src_old/tests/tmp diff --git a/index.json b/index.json deleted file mode 100644 index e69de29..0000000 diff --git a/index.json.backup b/index.json.backup deleted file mode 100644 index 24f1eb7..0000000 --- a/index.json.backup +++ /dev/null @@ -1,313 +0,0 @@ -[ - "0x106fe90d", - "0x11638259", - "0x11fabc8b", - "0x1342193f", - "0x1356de5", - "0x138664f2", - "0x14122a9e", - "0x143d4db6", - "0x147b3025", - "0x14ed80df", - "0x1652e9bb", - "0x17b3071d", - "0x19f06839", - "0x1a97f3b4", - "0x1bcafcfc", - "0x1c8e8588", - "0x1cd37a99", - "0x1d5e2815", - "0x1f1e33a", - "0x1f368c28", - "0x1f87af1d", - "0x20c949c8", - "0x20ec267d", - "0x210d686e", - "0x22cc02fc", - "0x233639e5", - "0x2400a2e1", - "0x241e053e", - "0x2426bad9", - "0x265697d0", - "0x289e792c", - "0x29b6a66", - "0x2b787968", - "0x2d544f8e", - "0x2dec9ae6", - "0x2e391a79", - "0x2f762f1", - "0x2fb4c21", - "0x312b387c", - "0x31b5af1b", - "0x33a48749", - "0x346c736d", - "0x34a3c18f", - "0x3544db61", - "0x3610001f", - "0x368fa7e1", - "0x375ab46e", - "0x384d95d0", - "0x38eb4948", - "0x3962be7c", - "0x3a68722d", - "0x3cdc17cf", - "0x3ec2547d", - "0x403b1c17", - "0x405d9bbc", - "0x40d26584", - "0x410ac6df", - "0x439b5db4", - "0x4405a6c4", - "0x444672ee", - "0x44b5e4a8", - "0x4535e0dd", - "0x46beb4c8", - "0x486a3727", - "0x48bba08f", - "0x4936ba84", - "0x4ed78d65", - "0x50b7c320", - "0x512d428e", - "0x5173218e", - "0x519f2bd3", - "0x51d62689", - "0x536e7a4f", - "0x54a16757", - "0x54e0ad7", - "0x553e1699", - "0x55d5e9cd", - "0x562a3066", - "0x5702e4ef", - "0x570bf390", - "0x581e5f18", - "0x59409c60", - "0x5aa31445", - "0x5ba8b325", - "0x5c03e78c", - "0x5c2fc4f7", - "0x5cac92f7", - "0x5d63aa74", - "0x5d92d98", - "0x5e6c7126", - "0x5ea21ab6", - "0x5ec79148", - "0x5f4857f1", - "0x601e31d9", - "0x60e87b87", - "0x612c820c", - "0x61e31c0", - "0x62ae49fb", - "0x633b3fea", - "0x6370fb17", - "0x63ac61d", - "0x6543179b", - "0x665b1d9e", - "0x669347e0", - "0x66e11179", - "0x67271121", - "0x68e9d000", - "0x693d21ac", - "0x69ba6b9b", - "0x69d919c8", - "0x6a051506", - "0x6a0dd48e", - "0x6a445490", - "0x6a4b6342", - "0x6a6d88de", - "0x6a901da5", - "0x6b211656", - "0x6c3b0dd5", - "0x6d306269", - "0x6d453ecd", - "0x6d8704a0", - "0x6e7a0a93", - "0x6f8431cc", - "0x6fc1cd9b", - "0x70124f89", - "0x7063d666", - "0x7174a7", - "0x72965b00", - "0x736fe2ba", - "0x73bdc880", - "0x74e3a840", - "0x75b89fa6", - "0x760f8605", - "0x7620ed36", - "0x764f36ff", - "0x76fda6c", - "0x777c0049", - "0x77e450d", - "0x77f4f97e", - "0x7893829", - "0x78a299fb", - "0x78cb8703", - "0x7c1fb3d", - "0x7cb3b7cb", - "0x7cfbe43e", - "0x7e9bd078", - "0x7eebdcbc", - "0x7fe93b7", - "0x8257345f", - "0x82db8086", - "0x8303bc8e", - "0x8377650f", - "0x83baf1eb", - "0x84f6dcff", - "0x85db562a", - "0x85df0864", - "0x85e23f05", - "0x8602d585", - "0x87fb3c8", - "0x8811c194", - "0x884f19a7", - "0x893ecc85", - "0x89e36552", - "0x8b158ce6", - "0x8baa3e7d", - "0x8cbd5ed8", - "0x8ce36b46", - "0x8d4aa1d2", - "0x8d9f4e00", - "0x8df1a948", - "0x934d2317", - "0x938fb9ec", - "0x94fa84ac", - "0x959b735a", - "0x963e6f75", - "0x968306bf", - "0x96890e2b", - "0x96bd1331", - "0x9752daec", - "0x9847b2ba", - "0x987a117e", - "0x9a5251a5", - "0x9b3344da", - "0x9bed422d", - "0x9c9886eb", - "0x9ccfa048", - "0x9e04d4e5", - "0x9e78a2eb", - "0x9f10cb00", - "0x9f413283", - "0xa014bf67", - "0xa020e8", - "0xa103f60c", - "0xa25d9dd3", - "0xa33402c7", - "0xa3390128", - "0xa35b677c", - "0xa4305208", - "0xa4b09a39", - "0xa4d5bb02", - "0xa5340fd5", - "0xa6b40beb", - "0xa6d11582", - "0xa7ab023", - "0xa9dda348", - "0xab54b1a8", - "0xacb05bca", - "0xadab08df", - "0xae612ec", - "0xaef23140", - "0xaf1f3fe6", - "0xaf52103e", - "0xb102468e", - "0xb41361ab", - "0xb4dd6e1e", - "0xb4e172c5", - "0xb6518fc7", - "0xb67aac17", - "0xb8163432", - "0xb8d83e24", - "0xba42514a", - "0xba512949", - "0xbc4b4932", - "0xbcccd02", - "0xbdb46404", - "0xbde186cf", - "0xbe2d6336", - "0xbe903242", - "0xbef5a849", - "0xbf043f86", - "0xbf5cf2c2", - "0xc0114c04", - "0xc0a4d08", - "0xc2f02731", - "0xc4200a6b", - "0xc5a189e", - "0xc613fe4f", - "0xc7413c40", - "0xc7702093", - "0xc7830a66", - "0xc87d9d9b", - "0xc9b5899b", - "0xc9d4f9d0", - "0xc9f5beb5", - "0xca4093af", - "0xca6c56ea", - "0xcb67e10f", - "0xcc0104bb", - "0xcdd6ba0d", - "0xce5ee17", - "0xce6d0da0", - "0xce719cf2", - "0xce787478", - "0xcf3eefcc", - "0xcfe52fff", - "0xcfffd59", - "0xd04d7fcb", - "0xd0560bc4", - "0xd09a9f0d", - "0xd20a1e56", - "0xd28fef9", - "0xd30d6a99", - "0xd45632fe", - "0xd5bfa167", - "0xd66bd72", - "0xd7def019", - "0xd7f5f938", - "0xd8a3c542", - "0xd8f5a031", - "0xd9cb880", - "0xda5d0ba7", - "0xdc4e9d27", - "0xddd443b6", - "0xdf26fbf3", - "0xdf55a4a", - "0xe1a3be24", - "0xe4ec9b0", - "0xe738eb4f", - "0xe7748156", - "0xe7da0d8c", - "0xe7fad1dd", - "0xe9e7a7c9", - "0xe9f08054", - "0xeb6492bf", - "0xec4d92c3", - "0xec5a6b90", - "0xed33e850", - "0xef9cc792", - "0xf045e0ac", - "0xf0698f3b", - "0xf169d853", - "0xf272e89d", - "0xf31b3e5b", - "0xf3ed3b65", - "0xf426f637", - "0xf4fcc2e3", - "0xf62e2e69", - "0xf713a55f", - "0xf71c1860", - "0xf7b6d5e3", - "0xf85fd8c9", - "0xf86b652a", - "0xf907da19", - "0xf93e5c05", - "0xf9ecec72", - "0xfa083c1f", - "0xfbb70c0e", - "0xfbe4ee37", - "0xfc158f3e", - "0xffc8d47b", - "0xfff07da1" -] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 617efd6..28826c7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,4 +10,5 @@ pyarrow==16.0.0 tiktoken==0.7.0 ultralytics boto3 -pyyaml \ No newline at end of file +pyyaml +pyairtable \ No newline at end of file diff --git a/src/annotations_indexer.py b/src/annotations_indexer.py new file mode 100644 index 0000000..9bc903d --- /dev/null +++ b/src/annotations_indexer.py @@ -0,0 +1,176 @@ +import json +import os.path +from datetime import datetime + +from rich.progress import track + +from integration.airtable import Annotation, Document, AnnotationSummary +from integration.s3 import list_files, create_session, download_annotations +from file_utils import read_config +from pyairtable.formulas import match +from integration.s3 import upload_files_to_s3 +from file_utils import get_path_in_workdir +from consts import Dirs + + +def sync_annotations(): + config = read_config() + session = create_session(config) + bucket = config['yc']['bucket']['annotations'] + + print("Getting the list of annotations from the Airtable...") + checked_annot = { + str(a['anno_id']): a['anno_md5'] + for a + in + map(lambda a: a.to_record().get('fields'), Annotation.all(fields=['anno_id', 'anno_md5'])) + if a + } + + # Get the list of files in the S3 bucket + remote_annot = list_files(bucket, session=session) + + # Find the files that are in the S3 bucket but not in the Airtable or has different md5 + diff = set(remote_annot.items()) - set(checked_annot.items()) + + if not diff: + print("No new annotations found in the S3 bucket") + return + + print(f"Found {len(diff)} files in the S3 bucket that are not in the Airtable") + # Download the annotations locally + downloaded_files = download_annotations(bucket, diff, session=session) + + annotations_to_save = [] + docs_cache = {} + for f, md5 in track(downloaded_files, description="Processing downloaded annotations..."): + with open(f, 'r') as file: + a = json.load(file) + anno_id = a['id'] + data = a['task']['data'] + doc_md5 = data['hash'] + + if md5 not in docs_cache: + docs_cache[doc_md5] = Document.first(formula=f"md5='{doc_md5}'") + + # Check if the annotation with the same id is already in the Airtable, maybe the annotation was updated + # in the object storage + known_md5 = checked_annot.get(str(anno_id)) + if not known_md5: + anno = Annotation(anno_id=anno_id) + elif known_md5 != md5: + print(f"MD5 mismatch for annotation `{anno_id}`. Known: {known_md5}, new: {md5}, updating...") + anno = Annotation.first(formula=f"anno_id={anno_id}") + else: + # If we are here it means function to find the difference between the airtable and s3 is not working, + # because here if id and md5 are same + # Just skipping it... + print(f"Annotation `{anno_id}` already in the Airtable") + continue + + anno.page_no = data['page_no'] + anno.image_link = data['image'] + anno.anno_md5 = md5 + anno.doc_md5 = doc_md5 + anno.last_changed = datetime.strptime(a['task']['updated_at'], "%Y-%m-%dT%H:%M:%S.%fZ") + anno.results = json.dumps([ + { + "original_width": r['original_width'], + "original_height": r['original_height'], + "x": r['value']['x'], + "y": r['value']['y'], + "width": r['value']['width'], + "height": r['value']['height'], + 'class': r['value']['rectanglelabels'][0], + } + for r + in a['result'] + ]) + annotations_to_save.append(anno) + + print(f"Saving {len(annotations_to_save)} annotations to the Airtable") + Annotation.batch_save(annotations_to_save) + for d in docs_cache.values(): + d.sent_for_annotation = True + Document.batch_save([d for d in docs_cache.values()]) + print("Syncing is done!") + + +def calculate_completeness(): + """ + Calculate the completeness of the annotations for every document sent for annotation + """ + + # Get all documents that were sent for annotation but the annotation is not completed + not_completed_docs = Document.all( + fields=['md5', 'pages_count'], + formula=match({ + Document.sent_for_annotation.field_name: True, + Document.annotation_completed.field_name: False + }) + ) + + # docs to update in the Airtable + docs_to_update = [] + # annotation summaries to update in the Airtable + anno_summaries_to_update = [] + + for doc in not_completed_docs: + # key is page_no, value is the annotations for the page + grouped = {} + # Get all annotations for the document + related_annotations = Annotation.all( + fields=['doc_md5', 'page_no', 'last_changed', 'results'], + formula=f"{Annotation.doc_md5.field_name}='{doc.md5}'" + ) + + # Group the annotations by page_no and get the latest annotation for every page + for a in map(lambda x: x.to_record().get('fields'), related_annotations): + page_no = a['page_no'] + last_changed = a['last_changed'] + + if page_no not in grouped: + grouped[page_no] = a + else: + print(f"Found duplicate annotation for page {page_no} in document {doc.md5}") + cur_value_update_time = grouped[page_no]['last_changed'] + if not cur_value_update_time or cur_value_update_time < last_changed: + grouped[page_no] = a + + completeness = len(grouped) / doc.pages_count + anno_sum = AnnotationSummary.get_or_create(doc_md5=doc.md5) + anno_sum.completeness = completeness + session = create_session() + + if completeness == 1.0: + # If the annotation is completed, mark the document as completed + doc.annotation_completed = True + anno_sum.missing_pages = None + docs_to_update.append(doc) + result = { + page_no: json.loads(annotation['results']) + for (page_no, annotation) + in grouped.items() + } + # save file locally + output_file = os.path.join(get_path_in_workdir(Dirs.ANNOTATION_RESULTS), f"{doc.md5}.json") + with open(output_file, 'w') as file: + json.dump(result, file, indent=4, sort_keys=True) + + # upload the file to the S3 bucket + upload_results = upload_files_to_s3( + [output_file], + bucket_provider=lambda c: c['yc']['bucket']['annotations_summary'], + session=session + ) + anno_sum.result_link = upload_results[output_file] + else: + missing_pages = [str(p) for p in range(1, doc.pages_count + 1) if p not in grouped] + anno_sum.missing_pages = f"{len(missing_pages)}: {missing_pages.__str__()}" + + anno_summaries_to_update.append(anno_sum) + + # Update the documents and the annotation summaries in the Airtable + Document.batch_save(docs_to_update) + AnnotationSummary.batch_save(anno_summaries_to_update) + print("Completeness calculation is done!") diff --git a/src/cli.py b/src/cli.py index d50ce72..94a8f31 100644 --- a/src/cli.py +++ b/src/cli.py @@ -1,15 +1,34 @@ import typer -from dispatch import layout_analysis +from dispatch import layout_analysis_entry_point, extract_text_entry_point +from annotations_indexer import sync_annotations, calculate_completeness app = typer.Typer() @app.command() -def layout( - force: bool = False +def inference( + force: bool = False ): """ Run PDF Document Layout Analysis service """ - layout_analysis(force) + layout_analysis_entry_point(force) + + +@app.command() +def sync(): + """ + Download new and changed annotations from the object storage and update the database + and calculate the completeness of the annotations + """ + sync_annotations() + calculate_completeness() + + +@app.command() +def extract(): + """ + Extract text from the annotated documents + """ + extract_text_entry_point() diff --git a/src/consts.py b/src/consts.py index 392a1d0..8cb5107 100644 --- a/src/consts.py +++ b/src/consts.py @@ -29,25 +29,12 @@ class Dirs(Enum): - """ - Enum with all the directories and files that are used in the project - - - ENTRY_POINT: Directory where all the files begins their journey - - NOT_A_DOCUMENT: Directory where files that are not documents at all are moved (e.g. images, archives) - - NOT_SUPPORTED_FORMAT_YET: Directory where files with formats that are not supported yet are moved - - NOT_TATAR: Directory where files that are not in Tatar language are moved - - DIRTY: Directory where files that are not processed yet are moved - - ARTIFACTS: Directory where all processing artifacts(e.g. txt files with extracted text) are stored - - COMPLETED: Directory where files that are processed successfully are moved - """ ENTRY_POINT = "000_entry_point" - WORK_IN_PROGRESS = "001_work_in_progress" PAGE_IMAGES = '100_page_images' LABEL_STUDIO_TASKS = "200_label_studio_tasks" - - NOT_A_DOCUMENT = "500_not_a_document" - NOT_SUPPORTED_FORMAT_YET = "510_not_supported_yet" - NOT_TATAR = "520_not_tt_document" + BOXES_PLOTS = '300_boxes_plots' + WAITING_FOR_EXTRACTION = '400_waiting_for_extraction' + ANNOTATION_RESULTS = '500_annotation_results' ARTIFACTS = '900_artifacts' - EXTRACTED_DOCS = '910_extracted_docs' + ANNOTATIONS = '910_annotations' diff --git a/src/dispatch.py b/src/dispatch.py index 864af9b..9b84c58 100644 --- a/src/dispatch.py +++ b/src/dispatch.py @@ -1,43 +1,108 @@ +import json import os import shutil +from pyairtable.formulas import match, OR from rich import print from rich.progress import track from consts import Dirs -from file_utils import pick_files, create_folders, calculate_md5, get_path_in_workdir -from layout_analysis import get_layout_analysis -from text_extraction import extract +from file_utils import pick_files, create_folders, calculate_md5, get_path_in_workdir, read_config +from integration.airtable import Document, AnnotationSummary +from layout_analysis import layout_analysis +from integration.s3 import download_annotation_summaries, create_session +from integration.yandex_disk import download_file_from_yandex_disk +from text_extraction import extract_content -create_folders() - - -def layout_analysis(force): +def layout_analysis_entry_point(force): """ Analyze the layout of the documents in the entry point folder """ if files := pick_files(get_path_in_workdir(Dirs.ENTRY_POINT)): for file in files: + # calculate the md5 of the file to use it as a unique identifier md5 = calculate_md5(file) - wip_path = os.path.join(get_path_in_workdir(Dirs.WORK_IN_PROGRESS), f"{md5}.{file.split('.')[-1]}") - if not os.path.exists(wip_path) or force: - shutil.copyfile(file, wip_path) - get_layout_analysis(file, md5) + # check if the document with the same md5 already exists in the Airtable + if doc := Document.first(formula=f"md5='{md5}'"): + if doc.sent_for_annotation and not force: + print(f"Document with md5 `{md5}` already sent for annotation. Skipping...") + continue + else: + doc = Document(md5=md5) + + # run layout analysis + pages_count = layout_analysis(file, md5) + + # update the document in the Airtable + doc.sent_for_annotation = True + doc.pages_count = pages_count + doc.save() + + # move file + shutil.move(file, get_path_in_workdir(Dirs.WAITING_FOR_EXTRACTION)) else: print(f"No files for layout analysis, please put some documents to the folder `{Dirs.ENTRY_POINT}`") -def extract_text(force): - if files := pick_files(get_path_in_workdir(Dirs.WORK_IN_PROGRESS)): - for file in track(files, description="Extracting text from the documents..."): - md5 = file.split("/")[-1].split(".")[0] - path_to_la = os.path.join(get_path_in_workdir(Dirs.LABEL_STUDIO_TASKS), f"{md5}.json") - if os.path.exists(path_to_la): - extract(file, path_to_la) - else: - print(f"Layout analysis for the document `{file}` not found, please run layout analysis command first") +def extract_text_entry_point(): + # get all documents that already annotated but the text is not extracted + not_extracted_docs = [ + doc.to_record()['fields'] + for doc + in Document.all( + formula=match({ + Document.annotation_completed.field_name: True, + Document.text_extracted.field_name: False + }), + fields=[Document.md5.field_name, Document.ya_public_key.field_name] + ) + ] + not_extracted_docs = {doc['md5']: doc['ya_public_key'] for doc in not_extracted_docs if doc} - else: - print( - f"No files for text extraction, please put some documents to the folder `{Dirs.ENTRY_POINT}` and run layout analysis command first") + if not not_extracted_docs: + print("All documents are already processed, it is time to do some annotation") + return + + # get all annotation summaries for the documents to get link for the results + anno_sums = [ + a.to_record()['fields'] + for a + in AnnotationSummary.all( + fields=[AnnotationSummary.doc_md5.field_name, AnnotationSummary.result_link.field_name], + formula=OR( + *[ + match({AnnotationSummary.doc_md5.field_name: doc_md5}) + for doc_md5 in not_extracted_docs + ] + ) + ) + ] + anno_sums = {a['doc_md5']: a['result_link'] for a in anno_sums if a} + + # download annotation summaries from the S3 + config = read_config() + session = create_session(config) + bucket = config['yc']['bucket']['annotations_summary'] + # key is MD5 of the document, value is the path to the downloaded file with summary results + downloaded_annotations = download_annotation_summaries(bucket, anno_sums, session=session) + + # place to store the documents that are waiting for extraction + dir_with_docs = get_path_in_workdir(Dirs.WAITING_FOR_EXTRACTION) + + # Get MD5 of the local documents waiting for extraction + # key is MD5 of the document, value is the path to the source document + local_md5s = {calculate_md5(file): file for file in pick_files(dir_with_docs)} + + # todo remove this line + downloaded_annotations = {k: v for k, v in downloaded_annotations.items() if k == '1a9e6d0120b09d498855adc755b780dc'} + + for md5, path_to_annot_res in track(downloaded_annotations.items(), "Extracting text from the documents..."): + # if document is not in the local folder, then download it from Yandex.Disk + if not (path_to_doc := local_md5s.get(md5)): + print(f"Document with MD5 `{md5}` not found in the local folder, downloading it from Yandex.Disk") + # download document from Yandex.Disk + path_to_doc = os.path.join(dir_with_docs, f"{md5}.pdf") + download_file_from_yandex_disk(not_extracted_docs[md5], path_to_doc) + + extract_content(md5, path_to_doc, path_to_annot_res) diff --git a/src/file_utils.py b/src/file_utils.py index 0c31e36..04b42d6 100644 --- a/src/file_utils.py +++ b/src/file_utils.py @@ -2,6 +2,8 @@ import os import sys +import yaml + from consts import Dirs @@ -49,3 +51,9 @@ def get_path_in_workdir(dir_name: str | Dirs, prefix: str = 'workdir'): paths = [parent_dir, '..', prefix, dir_name] path = os.path.join(*paths) return os.path.normpath(path) + + +def read_config(config_file: str = "config.yaml"): + with open(get_path_in_workdir(config_file, prefix="."), 'r') as file: + config = yaml.safe_load(file) + return config diff --git a/src/integration/airtable.py b/src/integration/airtable.py new file mode 100644 index 0000000..46e25e3 --- /dev/null +++ b/src/integration/airtable.py @@ -0,0 +1,64 @@ +from pyairtable.orm import Model, fields as F + +from file_utils import read_config + +config = read_config() + +class Annotation(Model): + anno_id = F.NumberField("anno_id") + page_no = F.NumberField("page_no") + image_link = F.UrlField("image_link") + anno_md5 = F.TextField("anno_md5") + results = F.TextField("results") + doc_md5 = F.TextField("doc_md5") + last_changed = F.DatetimeField("last_changed") + + class Meta: + base_id = config['airtable']['base_id'] + table_name = config['airtable']['table']['annotation'] + api_key = config['airtable']['api_key'] + + +class Document(Model): + # MD5 hash of the file + md5 = F.TextField("md5") + # MIME type of the file + mime_type = F.TextField("mime_type") + # Names of the document, more than one if there are duplicates + names = F.TextField("names") + # Yandex public url of the document + ya_public_url = F.UrlField("ya_public_url") + # Yandex public key of the document, used to retrieve temporary download link + ya_public_key = F.TextField("ya_public_key") + # Yandex resource id of the document. Together with public key it's used to identify the document + ya_resource_id = F.TextField("ya_resource_id") + # Count of pages in the document + pages_count = F.NumberField("pages_count") + # Flag to indicate if the document was sent for annotation + sent_for_annotation = F.CheckboxField("sent_for_annotation") + # Flag to indicate if the annotation is completed + annotation_completed = F.CheckboxField("annotation_completed") + # Flag to indicate if the text was extracted from the document + text_extracted = F.CheckboxField("text_extracted") + + class Meta: + base_id = config['airtable']['base_id'] + table_name = config['airtable']['table']['document'] + api_key = config['airtable']['api_key'] + +class AnnotationSummary(Model): + doc_md5 = F.TextField("doc_md5") + completeness = F.PercentField("completeness") + result_link = F.UrlField("result_link") + missing_pages = F.TextField("missing_pages") + + class Meta: + base_id = config['airtable']['base_id'] + table_name = config['airtable']['table']['annotation_summary'] + api_key = config['airtable']['api_key'] + + def get_or_create(doc_md5): + if not (summary := AnnotationSummary.first(formula=f"{AnnotationSummary.doc_md5.field_name}='{doc_md5}'")): + summary = AnnotationSummary(doc_md5=doc_md5) + return summary + diff --git a/src/integration/s3.py b/src/integration/s3.py new file mode 100644 index 0000000..7ddf9e4 --- /dev/null +++ b/src/integration/s3.py @@ -0,0 +1,111 @@ +import os.path + +import yaml +from boto3 import Session +from file_utils import calculate_md5, get_path_in_workdir, read_config +from rich.progress import track +import multiprocessing +from urllib.parse import urlparse + +from consts import Dirs + +CONFIG_FILE = "config.yaml" + + +def create_session(config=read_config()): + aws_access_key_id, aws_secret_access_key = map(config['yc'].get, ['aws_access_key_id', 'aws_secret_access_key']) + session = Session().client( + service_name='s3', + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + endpoint_url='https://storage.yandexcloud.net' + ) + return session + + +def upload_files_to_s3(files: [], bucket_provider, session=create_session(), **kwargs): + """ + Uploads files to the S3 bucket in the specified folder + """ + config = read_config() + bucket = bucket_provider(config) + + # get all files in the remote folder + remote_files = { + os.path.split(f['Key'])[-1]: f['ETag'].strip('"') + for f + in session.list_objects(Bucket=bucket, **kwargs).get('Contents', []) + # filter out directories + if not f['Key'].endswith("/") + } + + upload_result = {} + for l_file in track(files, description=f"Uploading files to the `{bucket}`..."): + l_file_name = os.path.split(l_file)[-1] + file_key = f"{kwargs['Prefix']}/{l_file_name}" if kwargs.get('Prefix') else l_file_name + + # check if the file with the same name exists on S3 and has the same digest + if l_file_name not in remote_files or remote_files[l_file_name] != calculate_md5(l_file): + session.upload_file( + l_file, + bucket, + file_key + ) + upload_result[l_file] = f"s3://{bucket}/{file_key}" + + return upload_result + + +def list_files(bucket: str, folder: str = "", session= create_session()): + """ + List files in the S3 bucket in the specified folder + """ + return { + f['Key']: f['ETag'].strip('"') + for f + in session.list_objects(Bucket=bucket, Prefix=folder).get('Contents', []) + } + + +def download_annotations(bucket: str, keys:[], session=create_session()): + """ + Download annotations from the S3 bucket + """ + downloaded_annotations = [] + download_folder = get_path_in_workdir(Dirs.ANNOTATIONS) + + for key, r_md5 in track(keys, description=f"Downloading annotations from the `{bucket}`..."): + output_file = os.path.join(download_folder, key) + if not (os.path.exists(output_file) and r_md5 == calculate_md5(output_file)): + session.download_file( + bucket, + key, + output_file + ) + downloaded_annotations.append((output_file, r_md5)) + + return downloaded_annotations + + +def download_annotation_summaries(bucket: str, keys, session=create_session()): + """ + Download annotation results from the S3 bucket + """ + downloaded_files = {} + download_folder = get_path_in_workdir(Dirs.ANNOTATION_RESULTS) + for md5, link in track(keys.items(), description=f"Downloading annotation results from the `{bucket}`..."): + output_file = os.path.join(download_folder, f"{md5}.json") + res = urlparse(link) + if not os.path.exists(output_file): + print(f"Downloading {link} to {output_file}") + session.download_file( + res.netloc, + res.path.lstrip("/"), + output_file + ) + downloaded_files[md5] = output_file + + return downloaded_files + + + diff --git a/src/integration/yandex_disk.py b/src/integration/yandex_disk.py new file mode 100644 index 0000000..cf12d45 --- /dev/null +++ b/src/integration/yandex_disk.py @@ -0,0 +1,24 @@ +import requests + +from file_utils import read_config + + +def download_file_from_yandex_disk(public_key: str, output_file: str): + """ + Download file from the Yandex Disk + """ + config = read_config() + resp = requests.get( + url=config['yandex_disk']['download_url'], + headers = {'Authorization': config['yandex_disk']['token']}, + params={ + "public_key": public_key, + }, + timeout=30 + ) + resp.raise_for_status() + download_link = resp.json()["href"] + resp = requests.get(download_link, timeout=30) + resp.raise_for_status() + with open(output_file, "wb") as f: + f.write(resp.content) \ No newline at end of file diff --git a/src/layout_analysis.py b/src/layout_analysis.py index 39fe98e..6dcf8b1 100644 --- a/src/layout_analysis.py +++ b/src/layout_analysis.py @@ -8,15 +8,15 @@ from consts import Dirs from file_utils import get_path_in_workdir -from s3_uploader import upload_files_to_s3 +from integration.s3 import upload_files_to_s3 REPO_ID = 'hantian/yolo-doclaynet' -MODEL_NAME = 'yolov9m' +MODEL_NAME = 'yolov10b' MODEL_CHECKPOINT = f"{MODEL_NAME}-doclaynet.pt" DPI = 300 -def get_layout_analysis(path_to_file: str, md5: str): +def layout_analysis(path_to_file: str, md5: str): """ Analyze the layout of the document @@ -24,11 +24,15 @@ def get_layout_analysis(path_to_file: str, md5: str): Then, it analyzes the layout of the pages and uploads the images and the layout analysis to S3. """ # Make images of the book pages - pages_details = _make_images_of_pages(path_to_file, md5) + pages_details, pages_count = _make_images_of_pages(path_to_file, md5) # Analyze the layout of the pages pages_details = _create_layout_analysis(pages_details, md5) # Upload the images to S3 - remote_files = upload_files_to_s3([f['path_to_image'] for f in pages_details.values()], f"images/{md5}") + remote_files = upload_files_to_s3( + [f['path_to_image'] for f in pages_details.values()], + lambda c: c['yc']['bucket']['images'], + Prefix=md5 + ) # Update the paths to the remote images for page_no, details in pages_details.items(): details["remote_path"] = remote_files[details["path_to_image"]] @@ -36,7 +40,12 @@ def get_layout_analysis(path_to_file: str, md5: str): # Export the predictions to the Label Studio format tasks_paths = _export_predictions(pages_details, md5) # Upload the task to S3 - upload_files_to_s3(tasks_paths, f"tasks/{md5}") + upload_files_to_s3( + tasks_paths, + lambda c: c['yc']['bucket']['tasks'], + Prefix=md5 + ) + return pages_count def _make_images_of_pages(path_to_file: str, md5: str): @@ -44,27 +53,31 @@ def _make_images_of_pages(path_to_file: str, md5: str): os.makedirs(output_dir, exist_ok=True) results = {} with pymupdf.open(path_to_file) as doc: + pages_count = doc.page_count for page in track(doc[:], description=f"Extracting images of the pages from `{md5}`..."): - path_to_image = os.path.join(output_dir, f"{page.number}.jpg") + path_to_image = os.path.join(output_dir, f"{page.number}.png") if os.path.exists(path_to_image): pix = pymupdf.Pixmap(path_to_image) else: - pix = page.get_pixmap(colorspace='rgb', alpha=False) - pix.save(path_to_image, 'jpg') + pix = page.get_pixmap(colorspace='rgb', alpha=False, dpi=DPI) + pix.save(path_to_image, 'png') results[page.number] = { "path_to_image": path_to_image, "width": pix.width, "height": pix.height } - return results + return results, pages_count def _create_layout_analysis(pages_details, md5: str): + # Create a directory for the plots + plots_dir = os.path.join(get_path_in_workdir(Dirs.BOXES_PLOTS), md5) + os.makedirs(plots_dir, exist_ok=True) + model = YOLO(hf_hub_download(repo_id=REPO_ID, filename=MODEL_CHECKPOINT)) for page_no, details in track(pages_details.items(), description=f"Analyzing layouts of the pages from `{md5}`..."): - img = details["path_to_image"] - pred = model(img, verbose=False) + pred = model.predict(details["path_to_image"], verbose=False) results = pred[0].cpu() boxes = results.boxes.xyxy.numpy() confs = results.boxes.conf.numpy() @@ -73,48 +86,27 @@ def _create_layout_analysis(pages_details, md5: str): page_layouts = [ { "bbox": x[0].tolist(), - "layout": [{ - "class": results.names[int(x[2])].lower(), - "conf": str(round(x[1], 2)), - "id": f"{page_no}::{idx}" - }] + "class": results.names[int(x[2])].lower(), + "conf": str(round(x[1], 2)), + "id": f"{page_no}::{idx}" } for (idx, x) in enumerate(zip(boxes, confs, classes)) ] - page_layouts = _merge_overlapping_boxes(page_layouts) - details["layout"] = page_layouts - - return pages_details + details["layouts"] = _post_process_layouts(page_layouts, page_no) + # Save the plot + results.save(os.path.join(plots_dir, f"{page_no}.png")) -def _merge_overlapping_boxes(page_layouts: list): - def is_overlapping(a, b): - return not (a[2] < b[0] or a[0] > b[2] or a[3] < b[1] or a[1] > b[3]) - - def merge(a, b): - return min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3]) - - for i in range(len(page_layouts)): - for j in range(i + 1, len(page_layouts)): - if is_overlapping(page_layouts[i]["bbox"], page_layouts[j]["bbox"]): - page_layouts[i]["bbox"] = merge(page_layouts[i]["bbox"], page_layouts[j]["bbox"]) - layouts = page_layouts[i]["layout"] - layouts.extend(page_layouts[j]["layout"]) - page_layouts[i]["layout"] = sorted(layouts, key=lambda x: x["conf"], reverse=True) - page_layouts.pop(j) - return _merge_overlapping_boxes(page_layouts) - return page_layouts + return pages_details def _export_predictions(pages_details, md5: str): - total_pages = len(pages_details) tasks = { page_no: { "data": { "image": details["remote_path"], "page_no": page_no, - "total_pages": total_pages, "hash": md5, }, "predictions": [ @@ -122,7 +114,7 @@ def _export_predictions(pages_details, md5: str): "model_version": MODEL_NAME, "result": [ { - "id": f"{md5}::{layout["layout"][0]["id"]}", + "id": f"{md5}::{layout["id"]}", "type": "rectanglelabels", "from_name": "label", "original_width": details["width"], @@ -137,11 +129,11 @@ def _export_predictions(pages_details, md5: str): "width": (layout["bbox"][2] - layout["bbox"][0]) * 100 / details["width"], "height": (layout["bbox"][3] - layout["bbox"][1]) * 100 / details["height"], "rectanglelabels": [ - layout["layout"][0]["class"] + layout["class"] ], } } - for layout in sorted(details["layout"], key=lambda x: (x["bbox"][1], x["bbox"][0])) + for layout in sorted(details["layouts"], key=lambda x: (x["bbox"][1], x["bbox"][0])) ], } ] @@ -149,12 +141,126 @@ def _export_predictions(pages_details, md5: str): for page_no, details in pages_details.items() } - tasks_dir = get_path_in_workdir(Dirs.LABEL_STUDIO_TASKS) + tasks_dir = os.path.join(get_path_in_workdir(Dirs.LABEL_STUDIO_TASKS), md5) + os.makedirs(tasks_dir, exist_ok=True) tasks_paths = [] for page_no, task in tasks.items(): - output_path = os.path.join(tasks_dir, f"{page_no}_{md5}.json") + output_path = os.path.join(tasks_dir, f"{page_no}.json") with open(output_path, "w") as f: json.dump(task, f, indent=4) tasks_paths.append(output_path) return tasks_paths + + +def _post_process_layouts(page_layouts, page_no): + page_layouts = _iou(page_layouts) + page_layouts = _semantic_transform(page_layouts, page_no) + return page_layouts + + +def _iou(page_layouts, threshold=0.8): + """ + Post-process the layout analysis results. + It aimed to mitigate the problem of overlapping bounding boxes. + """ + def intersection_area(box1, box2): + """Calculate the intersection area of two bounding boxes.""" + x1 = max(box1[0], box2[0]) + y1 = max(box1[1], box2[1]) + x2 = min(box1[2], box2[2]) + y2 = min(box1[3], box2[3]) + return calculate_area((x1, y1, x2, y2)) + + def calculate_area(box): + """Calculate the area of a bounding box.""" + x1, y1, x2, y2 = box + return max(0, x2 - x1) * max(0, y2 - y1) + + def merge(a, b): + """Merge two bounding boxes.""" + return min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3]) + + def choose_region(first, second): + _l1, _area1 = first + _l2, _area2 = second + + # choose the one with higher confidence + if _l1['conf'] > _l2['conf']: + return _l1 + elif _l1['conf'] < _l2['conf']: + return _l2 + # below if confidences are equal + # then choose the one with the smaller area + elif _area1 < _area2: + return _l1 + elif _area1 > _area2: + return _l2 + # below if confidence and areas are equal (but still can be various classes) + # then choose the first one + else: + return _l1 + + layouts = sorted(map(lambda x: (x, calculate_area(x['bbox'])), page_layouts), key=lambda x: x[1], reverse=True) + i = 0 + + while i < len(layouts): + l1, area1 = layouts[i] + j = i + 1 + + while j < len(layouts): + l2, area2 = layouts[j] + + # Calculate the intersection area of both regions + inter_area = intersection_area(l1['bbox'], l2['bbox']) + + # Check if the intersection area of both regions is greater than the threshold + ratio_inter_to_box1 = inter_area / area1 + ratio_inter_to_box2 = inter_area / area2 + if ratio_inter_to_box1 > threshold and ratio_inter_to_box2 > threshold: + # Merge the two regions, because their intersection area is big to consider them as one region + merged_bbox = merge(l1['bbox'], l2['bbox']) + l = choose_region((l1, area1), (l2, area2)) + l['bbox'] = merged_bbox + a = calculate_area(merged_bbox) + # remove old regions by their indexes and add the new merged region + layouts = [x for idx, x in enumerate(layouts) if idx not in (i, j)] + [(l, a)] + break + # Check if one region is almost full inside the other + elif ratio_inter_to_box1 > threshold or ratio_inter_to_box2 > threshold: + # Leave only the most confident region and remove the other + l = choose_region((l1, area1), (l2, area2)) + a = calculate_area(l['bbox']) + layouts = [x for idx, x in enumerate(layouts) if idx not in (i, j)] + [(l, a)] + break + else: + j += 1 + + i += 1 + + return [x[0] for x in layouts] + + +def _semantic_transform(page_layouts, page_no): + """ + Post-process the layout analysis results based on knowledge how books typically look like. + """ + prev_class = None + for idx, l in enumerate(sorted(page_layouts, key=lambda x: (x["bbox"][1], x["bbox"][0]))): + # replace all `titles` with `section-header` if the page number is greater than 1 + this_class = l["class"] + if this_class == "title" and page_no > 1: + l["class"] = "section-header" + # if this class is `page-header` and it is not the first element on the page and previous element is not another + # `page-header` then replace it with `text` + elif this_class == 'page-header' and idx > 0 and prev_class != 'page-header': + l["class"] = "text" + elif this_class == 'list-item': + l["class"] = "text" + return page_layouts + + + + + + diff --git a/src/layout_labeling.py b/src/layout_labeling.py deleted file mode 100644 index cb25dbb..0000000 --- a/src/layout_labeling.py +++ /dev/null @@ -1,69 +0,0 @@ -# import json -# import os -# -# from pymupdf import pymupdf -# from pymupdf.utils import getColor -# from rich.progress import track -# -# from consts import Dirs -# from file_utils import get_path_in_workdir -# -# -# def label_document(path_to_doc: str, path_to_la: str, md5: str): -# with pymupdf.open(path_to_doc) as doc, open(path_to_la) as laf: -# la = json.load(laf) -# for page in doc: -# page_no = str(page.number) -# -# if page_no not in la: -# raise Exception(f"Page `{page_no}` not found in layout analysis") -# -# shape = page.new_shape() -# for layout in la[page_no]: -# box = layout["bbox"] -# text = ", ".join([f"{x['class']} {round(float(x['conf']), 2)} {x['id']}" for x in layout["layout"]]) -# shape.draw_rect(box) -# color = get_color_by_class(layout["layout"][0]["class"]) -# # insert text box slightly above the bbox -# x1, y1, _, _ = box -# shape.insert_text( -# (x1, y1 - 1), -# text, -# color=(0, 0, 0), -# fill=color, -# fontsize=12, -# border_width=0.03, -# render_mode=2, -# ) -# shape.finish(color=color, width=0.1, fill=color, fill_opacity=0.2) -# shape.commit(overlay=True) -# doc.save(os.path.join(get_path_in_workdir(Dirs.LAYOUT_MARKED_DOCS), f"{md5}.pdf")) -# -# -# def get_color_by_class(cls: str): -# match cls: -# case "text": -# return getColor("DarkOliveGreen") -# case "picture": -# return getColor("Salmon") -# case "caption": -# return getColor("Khaki") -# case "section-header": -# return getColor("Red") -# case "footnote": -# return getColor("Sienna") -# case "formula": -# return getColor("Coral") -# case "table": -# return getColor("Green") -# case "list-item": -# return getColor("DarkViolet") -# case "page-header": -# return getColor("Sienna") -# case "page-footer": -# return getColor("Blue") -# case "title": -# return getColor("Cyan") -# case _: -# raise Exception(f"Unknown class `{cls}`") -# diff --git a/src/s3_uploader.py b/src/s3_uploader.py deleted file mode 100644 index 2eb6ecf..0000000 --- a/src/s3_uploader.py +++ /dev/null @@ -1,54 +0,0 @@ -import os.path - -import yaml -from boto3 import Session -from rich.progress import track - -from file_utils import calculate_md5, get_path_in_workdir - -CONFIG_FILE = "config.yaml" - - -def upload_files_to_s3(files: [], folder: str): - """ - Uploads files to the S3 bucket in the specified folder - """ - with open(get_path_in_workdir(CONFIG_FILE, prefix="."), 'r') as file: - config = yaml.safe_load(file) - - s3 = _create_session(config) - bucket = config['yc']['bucket'] - - # get all files in the remote folder - remote_files = { - os.path.split(f['Key'])[-1]: f['ETag'].strip('"') - for f - in s3.list_objects(Bucket=bucket, Prefix=folder).get('Contents', []) - # filter out directories - if not f['Key'].endswith("/") - } - - upload_result = {} - for l_file in track(files, description=f"Uploading files to the `{bucket}/{folder}`..."): - l_file_name = os.path.split(l_file)[-1] - - # check if the file with the same name exists on S3 and has the same digest - if l_file_name not in remote_files or remote_files[l_file_name] != calculate_md5(l_file): - s3.upload_file( - l_file, - bucket, - f"{folder}/{l_file_name}" - ) - upload_result[l_file] = f"s3://{bucket}/{folder}/{l_file_name}" - - return upload_result - - -def _create_session(config): - aws_access_key_id, aws_secret_access_key = map(config['yc'].get, ['aws_access_key_id', 'aws_secret_access_key']) - return Session().client( - service_name='s3', - aws_access_key_id=aws_access_key_id, - aws_secret_access_key=aws_secret_access_key, - endpoint_url='https://storage.yandexcloud.net' - ) diff --git a/src/text_extraction.py b/src/text_extraction.py index 555fb70..6abb98e 100644 --- a/src/text_extraction.py +++ b/src/text_extraction.py @@ -1,118 +1,106 @@ import json import os +import random +import string from collections import Counter +from itertools import groupby +from math import sqrt +from enum import Enum -from pymupdf import pymupdf +from pymupdf import pymupdf, Matrix, TEXT_PRESERVE_LIGATURES, TEXT_PRESERVE_IMAGES, TEXT_PRESERVE_WHITESPACE, \ + TEXT_CID_FOR_UNKNOWN_UNICODE +import re from consts import Dirs from file_utils import get_path_in_workdir +# todo bold inside the word +# todo Horizontal Rule Best Practices instead of asterisks +# todo Starting Unordered List Items With Numbers +# todo download books by md5 +# todo remove glyphen +# todo check if regions overlap +# todo define reading order +# todo sort layouts, including knowledge of page structure +# todo whitespace after the punctuations +# todo headers +# todo proper headers +# todo neytralize asterisks and special characters of Markdown +# todo first block can be a continuation of the previous block +# todo one table over 2 pages +# todo image and caption relations +# todo formula support +# todo ignore texts in tables and images +# todo if no text was deteceted, then do OCR +# todo separate title to a separate project +# todo update instruction +# todo draw bounding boxes on the pdf +# todo line wraps +# toto update screenshots for title +# check annotations from other +# +# special case for poetry +# sort footnotes as well +# tables can have captions too +# save copy of datasets +# check not all labels are present +# support for emails and urls -class Formatter: - pass +omitted_classes = ['page-header', 'page-footer'] +TEXT_EXTRACTION_FLAGS = ( + TEXT_PRESERVE_LIGATURES + & + ~TEXT_PRESERVE_IMAGES + & + TEXT_PRESERVE_WHITESPACE + & + TEXT_CID_FOR_UNKNOWN_UNICODE +) -# todo detect multiple bolds sequences -# todo line wrap between pages -# todo attention to the first and last layouts on a page -# todo write footnotes -def extract(path_to_doc, path_to_la): - """ - Extract text from the files in the entry point folder - """ - md5 = path_to_doc.split("/")[-1].split(".")[0] - output_path = os.path.join(get_path_in_workdir(Dirs.ARTIFACTS), f"{md5}.md") - with open(path_to_la, 'rb') as laf: - la = json.load(laf) - with pymupdf.open(path_to_doc) as doc, open(output_path, 'w') as output: - hsd = HeaderSizeDefiner(doc) - for page in doc.pages(): - res = process_page(page, la, hsd) - output.write(res) +class _SectionType(Enum): + TEXT = 1 + IMAGE = 2 + TABLE = 3 + FORMULA = 4 + HEADER = 5 -def analyze_font_sizes(doc): - """ - Analyze font sizes in the document - """ +class PageArchetype: + def __init__(self, layouts): + groups = {} + for k, g in groupby(layouts, key=lambda x: x['class']): + if k in omitted_classes: + continue + groups[k] = list(g) + footnote = groups.pop('footnote', []) + main_body = sorted([item for sublist in groups.values() for item in sublist], key=lambda l: (l['y'], l['x'])) + self.layouts = main_body + footnote + + def __iter__(self): + return iter(self.layouts) + + +class MarkdownFormatter: -def process_page(page, la, hsd): - page_text = [] - page_no = str(page.number) - page_layout = la.get(page_no) - - if not page_layout: - raise Exception(f"Page `{page_no}` not found in layout analysis") - page_layout = order_layouts(page_layout) - body, footnotes = page_layout - for l in body: - bbox = l['bbox'] - cls = l['layout'][0]['class'] - match cls: - case 'text' | 'section-header' | 'title': - formatted_text = extract_text(page, bbox, hsd) - page_text.append(formatted_text) - case 'page-header' | 'page-footer': - raise Exception(f"Unexpected layout type `{cls}`, it should be removed during layout ordering") - case _: - # raise Exception(f"Unsupported layout type `{cls}`") - pass - return "".join(page_text) - - -def extract_text(page, bbox, hsd): - result = [] - for b in page.get_text("dict", clip=bbox).get('blocks', []): - for l in b.get('lines', []): - for s in l.get('spans', []): - text = s.get('text') - if not text: - continue - text = text.strip() - flags = s['flags'] - superscript = bool(flags & 1) - italic = bool(flags & 2) - monospace = bool(flags & 8) - bold = bool(flags & 16) - if monospace: - text = f"`{text}`" - asterisks_count = (1 if italic else 0) + (2 if bold else 0) - text = f"{'*' * asterisks_count}{text}{'*' * asterisks_count}" - if superscript: - text = f"[^{text}]" - else: - text = hsd.header_for_size(s['size'], text) - result.append(text) - return " ".join(result) + "\n\n" - - -def order_layouts(page_layouts): - def sort(l): - return sorted(l, key=lambda x: (x['bbox'][1], x['bbox'][0])) - - def leave_most_confident_layout(l): - return list(map(lambda x: {'bbox': x['bbox'], 'layout': [x['layout'][0]]}, l)) - - grouped_layouts = {} - for l in page_layouts: - key = l['layout'][0]['class'] - if key in grouped_layouts: - grouped_layouts[key].append(l) - else: - grouped_layouts[key] = [l] - - # skip page headers and footers - _ = grouped_layouts.pop('page-header', []) - _ = grouped_layouts.pop('page-footer', []) - - footnotes = leave_most_confident_layout(sort(grouped_layouts.pop('footnote', []))) - body = leave_most_confident_layout(sort([item for sublist in grouped_layouts.values() for item in sublist])) - return body, footnotes - - -class HeaderSizeDefiner: def __init__(self, doc): + self.headers = {} + # accumulates the various sections of the current page, including texts, images, tables, formulas, etc. + self.sections = [] + # accumulates the text chunks of the current text block + self.spans = [] + self.image_counter = 0 + self._determine_header_level(doc) + self.monospace_in_progress = False + self.italic_in_progress = False + self.header_in_progress = False + self.superscript_in_progress = False + self.bold_in_progress = False + self.prev_section_type = None + + def _determine_header_level(self, doc): + # key is the font size, value is the count of the characters with this font size font_sizes = Counter() for page in doc.pages(): for block in page.get_text("dict").get('blocks', []): @@ -121,19 +109,251 @@ def __init__(self, doc): span_text = span.get('text') if not span_text: continue - font_size = round(span['size']) - font_sizes[font_size] += len(span_text) + font_sizes[round(span['size'])] += len(span_text) mcf = font_sizes.most_common() - print(mcf) # remove the most popular font size due to it is the font size of the normal text and do not need to be a header - mcf.pop(0) - mcf = sorted(map(lambda x: x[0], mcf), reverse=True) - self.headers = {} - for idx, size in enumerate(mcf[:6]): # take 6 the biggest font sizes as Markdown headers + most_common_font_size = mcf.pop(0)[0] + mcf = sorted( + filter( + lambda x: x > most_common_font_size, + map( + lambda x: x[0], + mcf + ) + ), + reverse=True + ) + # take 6 the biggest font sizes as Markdown headers, filter out the fonts bigger than the most common font size + # because they are not headers + for idx, size in enumerate(mcf[:6]): self.headers[size] = idx + 1 - def header_for_size(self, size, text): - size = round(size) - if size in self.headers: - return f"{'#' * self.headers[size]} {text}" + def _header_for_size(self, size): + return self.headers.get(round(size)) or 0 + + def _format_span(self, span, is_last_span): + text = span.get('text') + if not text: + return None + elif text.isspace(): + return text + + # check if the last character is a soft hyphen + is_hyphen = text.endswith('­') + # if it is the last span in line and not a hyphen, then add a space + if is_last_span and not is_hyphen: + text += " " + elif is_hyphen: + # remove soft hyphens that are used to split words over lines + text = text.rstrip('­') + + flags = span['flags'] + size = span['size'] + + superscript = bool(flags & 1) + italic = bool(flags & 2) + monospace = bool(flags & 8) + bold = bool(flags & 16) + + header_multiplier = self._header_for_size(size) + + # monospace should be the first, otherwise all formatting elements will be inside the monospace block + if self.monospace_in_progress and not monospace: + # there is a monospace block in progress but the current span is not monospace, so we need to close the + # existing monospace block + self._close_monospace() + elif not self.monospace_in_progress and monospace: + self.monospace_in_progress = True + text = f"`{text}" + + if self.italic_in_progress and not italic: + # there is an italic block in progress but the current span is not italic, so we need to close the + # italic block + self._close_italic() + elif not self.italic_in_progress and italic: + self.italic_in_progress = True + text = f"*{text}" + + if self.bold_in_progress and not bold: + # there is a bold block in progress but the current span is not bold, so we need to close the bold block + self._close_bold() + elif not self.bold_in_progress and bold and not header_multiplier: + # making a header bold does not make sense, because headers are already bold + self.bold_in_progress = True + text = f"**{text}" + + # superscript should be before header + if self.superscript_in_progress and not superscript: + # there is a superscript block in progress but the current span is not superscript, so we need to + # close the superscript block + self._close_superscript() + elif not self.superscript_in_progress and superscript: + self.superscript_in_progress = True + text = f"{text}" + + # header should be the last + if self.header_in_progress and not header_multiplier: + # there is a header block in progress but the current span is not a header, so we need to close + # the header + self._close_header() + elif not self.header_in_progress and header_multiplier: + self.header_in_progress = True + text = f"{'#' * header_multiplier} {text}" + return text + + def extract_text(self, bbox, ctxt): + page = ctxt['page'] + for b in page.get_text("dict", clip=bbox, flags=TEXT_EXTRACTION_FLAGS)['blocks']: + for li in b.get('lines', []): + spans = li.get('spans', []) + spans_len = len(spans) - 1 + for idx, s in enumerate(li.get('spans', [])): + is_last_span = idx == spans_len + if formatted_span := self._format_span(s, is_last_span): + self.spans.append(formatted_span) + + if self.spans: + self._close_existing_formatting() + text_block = re.sub(r' +', r' ', ''.join(self.spans).strip()) + self.sections.append((_SectionType.TEXT, text_block)) + self.spans = [] + + def _close_header(self): + self.header_in_progress = False + + def _close_monospace(self): + if self.monospace_in_progress: + self.monospace_in_progress = False + if self.spans: + self.spans[-1] = f"{self.spans[-1].rstrip()}`" + + def _close_italic(self): + if self.italic_in_progress: + self.italic_in_progress = False + if self.spans: + self.spans[-1] = f"{self.spans[-1].rstrip()}*" + + def _close_bold(self): + if self.bold_in_progress: + self.bold_in_progress = False + if self.spans: + self.spans[-1] = f"{self.spans[-1].rstrip()}**" + + def _close_superscript(self): + if self.superscript_in_progress: + self.superscript_in_progress = False + if self.spans: + self.spans[-1] = f"{self.spans[-1].rstrip()}" + + def _close_existing_formatting(self): + """ + Close all formatting blocks if they are in progress + Order of closing is important + """ + self._close_monospace() + self._close_italic() + self._close_bold() + self._close_superscript() + self._close_header() + + def extract_picture(self, bbox, ctxt): + page = ctxt['page'] + self.image_counter += 1 + file_name = f"{ctxt['md5']}-{self.image_counter}.png" + path_to_image = os.path.join(get_path_in_workdir(Dirs.ARTIFACTS), file_name) + page.get_pixmap(clip=bbox, matrix=Matrix(1, 1), dpi=100).save(path_to_image, "png") + self.sections.append((_SectionType.IMAGE, f"![image{self.image_counter}](./{file_name})")) + + def flush(self, output_file): + # headers, text blocks, tables, images, formulas + # for text blocks: + # - two line breaks after previous text line + # - one line break after the header, formulas, tables, images + # for tables two new lines after text, another table, image, but one line after the headers and formulas + # for formulas, images, header one new line before the section + + """ + Extract text from the specified bounding box on the page + + If block is the first, then it can be a continuation of the previous block + To check it, we need to check: + - there is any previous text block. If not, then it is a new block + - if the last text block on the previous page ends with a hyphen, then it is a continuation + - if the new block starts with long dash, then it is a new block + - if the new block not starts with a long dash and consists of one line, then it is a continuation + - if the new block has more than one line, and first line has indent, then it is a new block + """ + first_text_block_found = False + for ty, section in self.sections: + prev = self.prev_section_type + match ty: + case _SectionType.FORMULA | _SectionType.IMAGE: + section = f"\n{section}" + case _SectionType.TABLE if prev in (_SectionType.TEXT, _SectionType.TABLE, _SectionType.IMAGE): + section = f"\n\n{section}" + case _SectionType.TABLE if prev == _SectionType.FORMULA: + section = f"\n{section}" + case _SectionType.TEXT if prev in (_SectionType.FORMULA, _SectionType.IMAGE, _SectionType.TABLE): + section = f"\n{section}" + case _SectionType.TEXT if prev == _SectionType.TEXT: + # if this is the first text block on the page, then it can be a continuation of the previous block + # if not first_text_block_found: + # first_text_block_found = True + # # very common for dialogues + # if section.startswith('—') or section.startswith('–'): + # pass + # else: + # section = f"\n\n{section}" + # else: + section = f"\n\n{section}" + output_file.write(section) + self.prev_section_type = ty + self.sections = [] + + +def extract_content(md5, path_to_doc, path_to_la): + """ + Extract text from the files in the entry point folder + """ + print(f"Extracting text from the document with md5 `{md5}`...") + result_md = os.path.join(get_path_in_workdir(Dirs.ARTIFACTS), f"{md5}.md") + with open(path_to_la, 'rb') as f: + annotations = json.load(f) + + context = {'md5': md5} + + with pymupdf.open(path_to_doc) as doc, open(result_md, 'w') as result_md: + f = MarkdownFormatter(doc) + for page in list(doc.pages())[:]: + if not (page_layouts := annotations.get(str(page.number))): + raise Exception(f"Annotations for the page `{page.number}` not found") + + context['page'] = page + width = page.rect.width / 100 + height = page.rect.height / 100 + for idx, anno in enumerate(PageArchetype(page_layouts)): + x1 = anno['x'] * width + y1 = anno['y'] * height + x2 = x1 + anno['width'] * width + y2 = y1 + anno['height'] * height + bbox = [x1, y1, x2, y2] + + match anno['class']: + case 'picture': + f.extract_picture(bbox, context) + case 'table': + raise NotImplementedError("Table extraction is not implemented yet") + case 'formula': + raise NotImplementedError("Formula extraction is not implemented yet") + case 'poetry': + pass + # raise NotImplementedError("Poetry extraction is not implemented yet") + case 'page-header' | 'page-footer': + # we must not be here because we already filtered out these classes + continue + case _: + f.extract_text(bbox, context) + + # flush the page to the file + f.flush(result_md) diff --git a/workdir/900_artifacts/4f10abe73cc399ca31c3a9a13932a8f8.md b/workdir/900_artifacts/4f10abe73cc399ca31c3a9a13932a8f8.md deleted file mode 100644 index 319aa98..0000000 --- a/workdir/900_artifacts/4f10abe73cc399ca31c3a9a13932a8f8.md +++ /dev/null @@ -1,80 +0,0 @@ -ТАТАРСТАН РЕСПУБЛИКАСЫ ФӘННӘР АКАДЕМИЯСЕ Г. ИБРАҺИМОВ исемендәге ТЕЛ, ӘДӘБИЯТ ҺӘМ СӘНГАТЬ ИНСТИТУТЫ - -**Л.Х. Мөхәммәтҗанова** - -**ДӨНЬЯ ЦИВИЛИЗАЦИЯСЕНДӘ** **ТАТАР ДАСТАННАРЫ** - -Казан 2018 - -*Татарстан Республикасы Фәннәр академиясенең* *Г* *. Ибраһимов исемендәге Тел, әдәбият һәм сәнгать институты* *Гыйльми советы карары нигезендә нәшер ителә* - -**Фәнни редактор** филология фәннәре докторы, проф. ** **Ф.И. Урманчеев** - -**Рецензентлар:** - -филология фәннәре докторы **А.Х. Садекова,** филология фәннәре докторы, доцент **И.Г. Закирова** - -**Мөхәммәтҗанова Л.Х.** **М98 Дөнья цивилизациясендә татар дастаннары** / Л.Х. Мөхәм­ мәт­ җанова. – Казан: ТӘһСИ, 2018. – 280 б. ISBN 978-5-93091-264-7 - -Татарлар – халык иҗатының гүзәл әсәрләренә – дастаннарга бай халык. Билгеле бер чордан татар дастаннары кулъязмалар булып та­ ралганнар, милләттәшләребез тарафыннан кат-кат күчерелгәннәр, ә китап басу чорында күп тиражлы басмалар булып дөнья күргәннәр, кулдан-кулга йөреп укылганнар. Үз телләрендә эпик иҗатның мондый төренә караган ядкарьләре булу белән дөньяда сирәк халыклар гына го­ рурлана ала. Монографиядә милли эпос-дастаннар татар цивилизация­ сенең зур бер өлеше буларак күзаллана, авторның фәнни сүзе – татар дастаннарының дөньядагы эпос мирасында тоткан урыны хакында. - -Монография төрки халыклар эпосы, фольклоры, әдәбияты, тарихы белән кызыксынучыларга адреслана. - -**УДК 398** **ББК 82.3(2)** - -**ISBN 978-5-93091-264-7** - -**КЕРЕШ** - -Эпос – үз эченә киң тарихны, бик тирәндә калган борынгылык­ ны, чордан-чорга күчеп сыналган иң яхшы шигъри традицияләр­ не сыйдырган гаҗәеп катлам. Озын-озак яшәү дәверендә халык бар­ лыкка китергән эпик әсәрләр рухи тормышта зур урын били. Халыкта эпос бар икән, аңа карап, халыкның үзен дә күзалларга мөм­ кин. Эпосның хәле-халәтен аның эчтәлегендә яктыртылган вакыйга­ лар гына характерламый, жанрның үзе кебек үк, бу мәсьәлә дә гаять катлаулы. Мондый ядкарьләрнең сакланышы, кайчан, кайда, кемнән язып алынганлыгы, таралыш формасы, әсәргә халыкның мөнәсәбәте, аның популярлыгы, башка эпослардан аермасы, милли үзенчәлеге, шул ук вакытта генетик, ареаль, типологик охшашлыклар, жанрның галимнәр тарафыннан өйрәнелү дәрәҗәсе – эпосы булган халыклар өчен болар – һәрвакыт көн кадагындагы бик мөһим сораулар, аларга ачыклык кертү актуальлеген беркайчан югалта алмый. - -Дөнья фольклорында эпос – акциональ, вербаль һәм текстуаль кырлары булган иҗат төре, ягъни, гади генә итеп әйткәндә, бу – мах­ сус хәрәкәтләр, интонация, стиль кулланып, билгеле бер тәртиптә башкарыла торган эпик жанр; тел-авыз белән әйтелә-сөйләнә торган сүз сәнгате һәм бербөтен текст буларак кабул ителә торган күләмле әсәр. Төрле халыкларның эпосы гаять төрле. Дөньядагы эпосларны хронологик тирәнлек һәм географик киңлек бизмәне аша карасак, аларда шушы өч кырның үсеш кимәле төрлечә булганлыгы күренә. Билгеле бер чорда кайбер халыкларның эпосында акциональлек өстенлек иткән, кайбер мәдәниятләрдә ул вербаль якның нык үскән - -4 *Мөхәммәтҗанова Л.Х. Дөнья цивилизациясендә татар дастаннары* - -булуы белән алдыра, кайберләрендә исә текстуальлек алгы планга чыга. Хәлбуки, халыкта әдәби-мәдәни традицияләрнең тарихи фор­ малашу характерына бәйле рәвештә, эпослы халыкларда шушы өч кырның ­ кайсы да булса берсе нык үсеш алган була. Акциональ кыр өстенлек алган очракта эпос жанры архаиклыкны бөтен тулылыгы белән саклый, мондый иҗат хәрәкәт-йола элементларын да саклаган күләмле сөйләмә эпос барлыкка килүгә китергән. Язма культура һ.б. факторлар йогынтысын кичергән эпос исә акциональ һәм беренчел вербальлектән ераклашкан. - -Нәкъ менә язма культура йогынтысында трансформация киче­ реп формалашкан эпоска мисал дөнья фольклорында бихисап, халык иҗатында мондый төрнең формалашуы Урта гасыр­ ларга ук туры килә. Борынгы Һиндстанның классик эпопеяларыннан алып Урта га­ сырларның соңгырак чоры һәм хәтта Яңа заманга караган текстлар­ ны галимнәр шушы категориядә карый. Хронологиясе гаять тирән булган кебек үк, китап культурасы һәм кулъязмачылык белән тирән керешкән эпосның географиясе дә шактый киң – Көньяк-Көнчыгыш Азиядән алып Төньяк Африка һәм Скандинавиягәчә дип билгеләнә. Эпосның акциональлектән һәм сөйләмә харктердан бераз читләшкән, язма фольклор төшенчәсен барлыкка китерүдә зур роль уйнаган әле­ ге специфик төрендә фольклор һәм әдәби традицияләр үзара тыгыз бәйләнгән, хәтта чуалып беткән була. [^1] - -Татарлар – шулай ук дастанлы халык. Татар эпос-дастаннары ди­ гәндә иң беренче чиратта китаби дастаннар алгы планга чыга. Идел буе татарлары халык иҗатының чын мәгънәсендә таҗы булган әлеге гүзәл әсәрләр төрле вариантта үз заманы өчен бик тә популяр бул­ ган. Бөек Тукай сүзләре белән әйткәндә, боларны «кечкенә чагында укып, җыламыйча үскән ирләремез вә хатыннарымыз сирәктер» [^2] . Гомумтөрки һәм дөнья эпосы яссылыгында татар халык дастаннары мәсьәләсе – халыкның рухи мирасын өйрәнү кысаларында гына кала алмый, ул – дөньякүләм эпос өйрәнү фәнни проблемасы да. - -«Түләк», «Бүз егет», «Кыйссаи авык», «Кыйссаи Сәкам», «Гый­ сә улы Амәт», «Шаһсәнәм һәм Гариб», «Хурлуга белән Һәмра», «Каһарман Катил», «Кисекбаш кыйссасы», «Чура батыр хикәяте», «Күр углы - -Солтан» кулъязмалары яисә басмалары, ике гашыйкның мәхәббәт та­ рихын бәян итүдән гыйбарәт романтик рухлы «Таһир белән Зөһрә», «Йосыф китабы», «Сәйфелмөлек», «Ләйлә белән Мәҗнүн» нөсхәләре, «Идегәй» («Идегә»)нең Нигъмәт Хәким һәм Нәкый Исәнбәт исемнәре белән мәгълүм җыйнама-тәнкыйди текстлары һ.б. – болар татар ки­ тап культурасын үстерүгә зур йогынты ясаган, язма әдәби-мәдәни процессны тагын да югарырак баскычка күтәрүдә әһәмияте бәяләп бетергесез гүзәл дастани мирасыбыз үрнәкләре. Бу әсәрләрнең татар телле иң камил, иң төзек вариантлары язмага теркәлеп, кулъязма нөсхәләр яисә китапка әверелеп таралган. Авторлы әдәби дастан­ нардан һәм импровизатор иҗаты булган традицион сөйләмә дастан­ нардан аермалы буларак, мондый төр эпоска карата фәндә «китаби дастан» төшенчәсе кулланыла. Бездә язма-китаби дастаннар бар­ лыкка килү тенденциясе, эпик иҗатның мондый төренә омтылыш борынгыдан ук килә. Сюжет, тематикасы буенча кулъязма һәм хәт­ та китап булып басылган дастаннарның формалашу тарихын һәм таралуын Урта гасырлар ахыры һәм аннан соңгы чор дип билгеләү гадел булыр. Казан татарларында китаби дастаннарның һәркайсы­ ның халык тормышында урын алуының үз юлы, иҗтимагый-­ тарихи сәбәпләре бар. - -Китапларга мөнәсәбәттәге фәнни теориядә «кулъязма ки­ тап» [^1] дип аталган үзенчәлекле шартлы термин бар. «Кулъязма китап» сүзтезмәсе төрле өлкәгә караган төрле жанрдагы чагыштыр­ мача күләмле язмаларга карата кулланыла. Кулъязма китап дип аталуның иң алгы планда тора торган шарты – текстның билгеле бер эзлек­ лелектә кулдан язылган һәм, китап рәвешенә китереп, төпләнгән булуы. Казан татарлары – Идел буе Болгары чорында һәм аннан да алдарак формалашып, соңрак Алтын Урда һәм Казан ханлыгы чо­ рында ныгыган язма культура варислары. Мең еллык язма мәдә­ нияткә ия халыкның китап культурасы югары булу – табигый хәл. Китаплы булу халыкның барлык өлкәсенә бик зур тәэсир ясаган, шул исәптән халыкның тел-авыз иҗаты саналган традицион сүз сәнгате дә язмачылыктан читтә кала алмаган. Бу күренеш ­ фольклористикада - -язма формада барлыкка килгән һәм кулъязмалар булып таралган эпос-дастаннарга мөнәсәбәттә кулланыла торган «татар китаби дастаны» терминының килеп чыгышын һәм мәгънәсен аңлауны җиңеләйтә. Кулъязма китаплы мәдәният шартларында барлыкка килгән мондый төр дастаннар безнең көнннәргә борынгы кулъязма­ лар, соңрак басма китаплар булып килеп ирешкән. - -Язма әдәби-мәдәни процессларның татар халык иҗатына, аерым алганда дастан жанры формалашуга йогынтысы бик зур булганлыгы бәхәс уятмый. Татар басма китабының тарихы акаде­ мик Ә. Кәримуллин хезмәтләрендә бәян ителсә [^1] , кулъязма китапка мөнәсәбәттә галимнәребез М. Госманов [^2] , М. Әхмәтҗанов [^3] һ.б. саллы хезмәтләрен багышладылар. Татарның бик борынгыдан килә торган кулъязма китабы да, XIX гасырның башыннан дөнья күрә башлаган тулы канлы милли басма китабы да – мәдәниятебез һәм әдәбияты­ быз тарихын өйрәнү өчен үтә кыйммәтле чыганак. Татарларның кулъязма һәм басма китабы бик бай һәм зур тарихка ия. Китап та­ рихын, биг­ рәк тә меңәрләгән кулъязма китапларны өйрәнү иң элек кулъязма материалны туплаудан башлана. Казанда Татарстан Фәннәр акаде­ мия­ се, Милли китапханә, Казан федераль универси­ теты, Милли архив һ.б. фәнни үзәкләрдә татар кулъязма мирасын җыю-туплау һәм аның сакланышын тәэмин итү юнәлешендә мак­ сатчан эш алып барыла. - -Татар халкының язма-китаби дастаннары дигәндә игътибар үзәгенә казан татарлары эпик иҗаты чыга. Мәгълүм булганча, ка­ зан татарлары – безнең халыкны тәшкил итә торган зур этник төр­ кемнәрнең берсе. Әлбәттә, халык иҗаты әсәрләренә карата мондый аерымлау шактый шартлы, чөнки эпик һәм лиро-эпик әсәрләр ара­ сында бер милләтнең төрле төркемнәре өчен генә түгел, берничә халык өчен бердәй уртак сюжетлылары билгеле. Билгеле сюжетка - -кардәш халыкларда традицион дастан сөйләнеп, бездә шул ук сюжет­ ка язма-китаби дастан формалашкан ­ булырга мөмкин. - -Кулъязма һәм басма китапларга теркәлгән эпик иҗат – нигездә Идел буенда яшәүче казан татарларына хас милли үзенчәлекләрне үз эченә алган, шуларның икътисади һәм мәдәни үсешенә ярашып формалашкан, татар халкы дастани байлыгының иң зур өлешен биләп торган әсәрләр. Хакыйкатьтә халык иҗатындагы язма харак­ терлы дастаннар татарларның урта диалектта сөйләшә торган аре­ алын гомумән били. XVII гасырдан башлап Идел буе татарларының Көнбатыш Себергә күпләп күчеп утыруларын исәпкә алганда [^1] , казан татарларының себер татарлары культурасына, шул исәптән фольк­ лорына да ныклы тәэсирен күзаллау кыен түгел. Шуңа күрә язуга теркәлгән дастаннар традициясенең географиясе дә безнең чорга якынлашкан саен киңәя төшә. В.М. Жирмунский билгеләп үткәнчә [^2] , соңгырак чор төрки эпосына авыз иҗатыннан язмага күчә бару тен­ денциясе гомумән хас. - -Хәзерге татарлар составындагы төп этник төркемнәрнең һәр­ кайсының борынгы бабаларын Алтын Урда дәүләте хакимлеге бер­ ләштергән. Шулай булса да, бу әле бер үк дәүләт составына кергән, бү­ ген бер милләт булып яшәүче татарларның һәрбер этник төркемендә тормыш-яшәү рәвеше, икътисади һәм мәдәни үсеш шартлары бердәй булган дигән сүз түгел. Идел буе, ягъни казан татарлары яшәешен­ дә һәм гореф-гадәтендә зур рольне соңрак Алтын Урданың бер өле­ шенә әверелгән, әмма күпмедер дәрәҗәдә үзенең мөстәкыйльлеген, төгәлрәк әйткәндә, үз йөзен, үзенә генә хас колоритын саклап кала алган Болгар дәүләте үтәгән. - -Ә Идел буе Болгары борын-борыннан үзенең утрак тормыш алып баруы белән мәгълүм. Биредә утрак тормыш Урта гасырга хас калалары, һөнәрчелек һәм халыкның җир эшкәртеп яшәве, тыш­ кы һәм эчке сәүдәсе белән данлыклы. Идел буе Болгар дәүләтендә безнең бабаларның үз язу системасы җайга салынган, язма әдәбият югары дәрәҗәгә җиткән, гуманитар гыйлемнәр тармагы шактый зур үсеш алган булган. XIII гасыр башында биредә атаклы шагыйрь Кол Галинең мәгълүм сюжетка иҗат ителгән «Кыйссаи Йосыф» поэмасы - -үзе генә дә – биредәге җирле халыкта язма әдәби телнең, әдәбиятның ни дәрәҗәдә югары торганлыгына кире каккысыз дәлил. Мондый зур әдәби талант фәкать үзенә кадәр һәм үз чорында булган бай язма тра­ дицияләр җирлегендә генә туа ала. - -Чыннан да, Идел буе Болгары, соңрак Казан ханлыгы ул – чал гасырлардан ук элгәрләренең язма иҗат традицияләрен үз чоры тормышына китереп җиткерә алган, алга таба үз мәдәниятенең төр­ ле тармакларын язмачылыкка нигезләп үстергән һәм шуларның бәрәкәтле нәтиҗәсенә ирешә алган, язма иҗатка, фәнгә һәм гомумән китапка ихтыяҗы һәм ихтирамы феномен дәрәҗәсенә җиткән төр­ киләр – татарлар төбәге. Казан ханлыгы оешканга кадәр Алтын Урда составында яшәгәндә дә бу төбәк дәүләтнең үзәге саналган һәм шәһәр тормышы зур үсеш алган Алтын Урданың әлеге уңайлы тер­ риторияләре аеруча алга киткән булган. Дәүләтнең икътисади үсеше закончалыклы төстә рухи үсешкә – китап культурасына этәргеч бир­ гән. Урта Идел татарлары иҗатында дастаннарның китаби төре фор­ малашу һәм ныгып урнашу язма әдәби-мәдәни процессларның логик дәвамы булып күзаллана. - -Язулы һәм дәүләтле халыклар эпосының язмышына карата Х.К. Короглы түбәндәгечә яза: «Язулы борынгы халыкларның (фар­ сы, һинд һ.б.) күбесендә эпос, язуга күчерелү белән үк, сөйләмә фор­ мада яшәүдән туктаган, чөнки дастан әйтүчене (сказитель) кыйсса­ хан (чтец) алыштырган һәм ул авыл һәм шәһәрнең озын кичләрендә шул ук эпосны үзе «канунлаштырган» формада халыкка укып бирә торган булган. Шәһәр һәм авыл культурасы югары утрак тормышлы иранлылар, һинд, греклар,тарихи үсеш­ ләренең билгеле бер чорында, телдән башкарыла торган эпоска караганда язма эпосны кулайрак күреп, тулысынча язма эпоска күчкәннәр» [^1] . - -Күп гасырлык язма мираска ия татарларда да эпик иҗатның язмышы әлеге закончалыкка шактый тәңгәл. Дөрес, гәрчә халык­ та бик киң таралыш алган кайбер дастаннарыбызның кыскача эчтәле­ ген я булмаса бер өзеген көйләп-сөйләп бирү фактларына юлыкка­ ласак та, хәтта кыйссачылар да Идел буе татарларында әллә ни по­ пуляр булган дип әйтә алмыйбыз, аларны кыйссачы яисә кыйссахан дип атау да гадәткә кермәгән. Моның сәбәпләре күләмле эпик әсәр­ нең язма культура белән кисешүеннән килгәнлеге ачык аңлашыла. - -Шулай ук Болгар дәүләтендә әле X гасырга кадәр үк кереп ныгыган ислам дине дә, телдән башкарыла торган иҗатка карата халыкның игътибарын шактый киметеп, язма сүзгә һәм китапка ихтирамны бермә-бер арттыруга этәргеч ясаган. Бу исә үз чиратында дастанна­ рыбызның халыкның үзе тарафыннан ук язуга күчерелүенә, ягъни китабилашуына, алай гына да түгел, гарәп-мөселман язма чыганак­ лары ­ йогынтысында Идел-Чулман буе татарлары иҗатында әдәби чыгышлы дастаннарның өр-яңа язма милли версияләре таралуга китергән. Монда беренчел чыганакның язма булуы да, Идел буе татарларында эпик фольклорда әсәрне телдән башкаруга караган­ да язмага әверелдереп тарату калыбы камилләшкән булу да үз ро­ лен уйнаган. - -Халыкның язмача иҗат ителгән эпосы татарларда дастаннар белән генә дә чикләнми. Әйтик, бәетләр, нигездә шәкертләр һәм укы­ мышлы муллалар тарафыннан язылып, халыкта язмача һәм телдән тарала торган булганнар. Бу үзенчәлеккә XIX йөз ахырында К. На­ сыйри һәм шул турыда язып чыккан Н.Ф. Катанов әһәмият биргән. Бу хакта галим болай ди: «К.Насыйров сүзләренә караганда, бәетләр чыгару белән мәдрәсәдә, мөселман урта мәктәпләрендә укучы яисә укыган гадәти яшь егетләр, ә кайвакыт мөселман гыйлеме белән та­ ныш булган карт-коры мавыга» [^1] . - -Халыкның уку-язу белән турыдан-туры шөгыльләнүче катламы даирәсендә туган иҗатның язуга теркәлүе гаҗәп түгел. Чыннан да, халыкта шактый актив яши торган бәетләрнең яшәү формасы әле бүгенге көндә дә текстны кәгазьгә язып кую тради­ циясеннән аерыл­ гысыз. Татарстан Фәннәр академиясе Г. Ибра­ һимов исемендәге Тел, әдбият һәм сәнгать институты тарафыннан татарлар яши торган төрле төбәкләргә (әйтик, Татарстан районнары, Самара, Чиләбе, Оренбург, Әстерхан өлкәләре, Пермь крае һ.б. төбәкләрдәге татар авылларында) оештырылган фольклор экспедицияләрендә туплан­ ган тәҗрибәдән чыгып фикер йөрткәндә, информант, бәет текстын хәтердән яхшы белсә дә, кагыйдә буларак, аны һәрвакыт дәфтәр битенә – язуга төшергән була, текстны тыңлаучыга язудан көйләп укып җиткерә. - -10 *Мөхәммәтҗанова Л.Х. Дөнья цивилизациясендә татар дастаннары* - -Бәет жанрының килеп чыгышы, формалашуы һәм үсеше мәсьә­ ләләренә киң тукталып, Ф.И. Урманчеев түбәндәгечә яза: «Ничек кенә булмасын, әле XX гасыр башында һәм утызынчы елларда ук бәет­ ләрнең язмача һәм телдән барлыкка килүе һәм таралуы ачык бил­ геләнгән иде. Бәетләрнең иҗат ителүендә һәм таралуында мәдрәсә шәкертләренең роле зур булу белән бергә биредә Урта Идел татарла­ рында мәгърифәт һәм грамоталылыкның бик киң таралган булуын да күздә тотарга кирәк. Мондый шартларда әсәрнең нинди формада иҗат ителүе асылда принципиаль әһәмияткә ия түгел» [^1] . Чыннан да, Идел буе татарларында мәгърифәт һәм грамоталылыкның фольк­ лорның кайбер жанрларына тирән йогынтысы, милли-мәдәни мен­ талитетка тәэсир итеп, халык иҗаты процессына әһәмиятле үзгә­ решләр керткән булуы бәхәссез. - -Фольклорның эпик жанрларында беренчел текст ул – әсәрнең чичән авызыннан турыдан-туры язып алынганы, вербаль чыгышның аутентик рәвештә кәгазьгә төшерелгәне. Безнең көннәргә якынай­ ган саен мондый текст белән очрашу мөмкинлеге кыенлашканнан кыенлаша бара, чөнки гасырлар буена барган китап культурасы ха­ лыкның тел-авыз иҗатына сыйфат үзгәрешләрен шактый тирән һәм масштаблы кертә килгән. Төрки телле халыкларның саф фольклор әсәрләрен, ягъни «тере» эпик текстларны системалы рәвештә тупла­ ган чыганак дип, әйтик, В.В. Радлов тарафыннан төзелгән «Образцы народной литературы тюркских племен» җыентыгын [^2] атый алабыз. XIX йөзнең 60 нчы елларыннан алып XX йөз башынача дөнья күргән бу фундаменталь җыентыкта үзбәк, каракалпак, төрекмән халыкла­ рыннан кала барлык төркиләрнең дә фольклор әсәрләре турыдан-­ туры, икенче төрле әйткәндә, ничек сөйләнгән, шул рәвешчә ча­ гылыш та тапкан. Әлеге томнарның фольклористикада төрки эпосны һәм фольклорны фәнни нигезгә куйган беренче хезмәт буларак та­ нылуы һәммәбезгә дә мәгълүм. В.В. Радлов бастырган томнардагы текстлар – беренчел чыганакка турыдан-туры нисбәтле, кириллица­ да диактрик билгеләр белән фонетик транскрипциядә бирелгән ори­ гинал язмалар. -